Release: dev to main (Cities & Knights) #9
@@ -58,6 +58,7 @@ import org.prolibertate.games.game.pirates.PiratesLevel
|
|||||||
import org.prolibertate.games.game.pirates.PiratesOptions
|
import org.prolibertate.games.game.pirates.PiratesOptions
|
||||||
import org.prolibertate.games.game.president.PresidentOptions
|
import org.prolibertate.games.game.president.PresidentOptions
|
||||||
import org.prolibertate.games.game.catan.CatanOptions
|
import org.prolibertate.games.game.catan.CatanOptions
|
||||||
|
import org.prolibertate.games.game.catan.CatanRuleSet
|
||||||
import org.prolibertate.games.game.sequence.SequenceOptions
|
import org.prolibertate.games.game.sequence.SequenceOptions
|
||||||
import org.prolibertate.games.game.tayu.TayuLevel
|
import org.prolibertate.games.game.tayu.TayuLevel
|
||||||
import org.prolibertate.games.game.tayu.TayuOptions
|
import org.prolibertate.games.game.tayu.TayuOptions
|
||||||
@@ -334,9 +335,55 @@ fun teamForSeat(gameId: String, seat: Int, seatCount: Int): Int = when (gameId)
|
|||||||
else -> seat
|
else -> seat
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The targets the setup screen offers for a Katan rule set. */
|
||||||
|
internal fun catanPointsChoices(ruleSet: CatanRuleSet): List<Int> = when (ruleSet) {
|
||||||
|
// Five is a short game for trying things out; ten is the published target.
|
||||||
|
CatanRuleSet.BASE -> listOf(5, 8, 10, 12)
|
||||||
|
// Cities & Knights is played to thirteen, which is also why it runs longer.
|
||||||
|
CatanRuleSet.CITIES_AND_KNIGHTS -> listOf(8, 10, 13, 15)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The published target for a rule set. */
|
||||||
|
internal fun catanDefaultPoints(ruleSet: CatanRuleSet): Int = when (ruleSet) {
|
||||||
|
CatanRuleSet.BASE -> 10
|
||||||
|
CatanRuleSet.CITIES_AND_KNIGHTS -> 13
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [options] switched to [ruleSet]. The target moves to the new rule set's own
|
||||||
|
* default unless the one already chosen is on its menu too.
|
||||||
|
*/
|
||||||
|
internal fun catanSwitchRules(options: CatanOptions, ruleSet: CatanRuleSet): CatanOptions =
|
||||||
|
options.copy(
|
||||||
|
ruleSet = ruleSet,
|
||||||
|
pointsToWin = options.pointsToWin.takeIf { it in catanPointsChoices(ruleSet) && it != catanDefaultPoints(options.ruleSet) }
|
||||||
|
?: catanDefaultPoints(ruleSet),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun catanRuleSetName(ruleSet: CatanRuleSet): String = when (ruleSet) {
|
||||||
|
CatanRuleSet.BASE -> "Base game"
|
||||||
|
CatanRuleSet.CITIES_AND_KNIGHTS -> "Cities & Knights"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun catanRuleSetBlurb(ruleSet: CatanRuleSet): String = when (ruleSet) {
|
||||||
|
CatanRuleSet.BASE ->
|
||||||
|
"Settle the island, trade, and play development cards. Longest Road and Largest Army score."
|
||||||
|
CatanRuleSet.CITIES_AND_KNIGHTS ->
|
||||||
|
"Cities make commodities, knights hold off the barbarians, and improvements earn progress " +
|
||||||
|
"cards and metropolises. No development cards; the robber sleeps until the first attack."
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) -> Unit) {
|
private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) -> Unit) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
ChipRow(
|
||||||
|
label = "Rules",
|
||||||
|
values = CatanRuleSet.entries,
|
||||||
|
selected = options.ruleSet,
|
||||||
|
display = ::catanRuleSetName,
|
||||||
|
onSelect = { onChange(catanSwitchRules(options, it)) },
|
||||||
|
)
|
||||||
|
Text(catanRuleSetBlurb(options.ruleSet), style = MaterialTheme.typography.bodySmall)
|
||||||
ChipRow(
|
ChipRow(
|
||||||
label = "Players",
|
label = "Players",
|
||||||
values = listOf(3, 4),
|
values = listOf(3, 4),
|
||||||
@@ -346,8 +393,7 @@ private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) -
|
|||||||
)
|
)
|
||||||
ChipRow(
|
ChipRow(
|
||||||
label = "Game to",
|
label = "Game to",
|
||||||
// Five is a short game for trying things out; ten is the published target.
|
values = catanPointsChoices(options.ruleSet),
|
||||||
values = listOf(5, 8, 10, 12),
|
|
||||||
selected = options.pointsToWin,
|
selected = options.pointsToWin,
|
||||||
display = { "$it points" },
|
display = { "$it points" },
|
||||||
onSelect = { onChange(options.copy(pointsToWin = it)) },
|
onSelect = { onChange(options.copy(pointsToWin = it)) },
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import androidx.compose.ui.geometry.Offset
|
|||||||
import androidx.compose.ui.geometry.Size
|
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.PathEffect
|
||||||
import androidx.compose.ui.graphics.StrokeCap
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
@@ -58,6 +59,8 @@ fun CatanBoardView(
|
|||||||
style: CatanStyle = LocalCatanStyle.current,
|
style: CatanStyle = LocalCatanStyle.current,
|
||||||
/** Hexes to light up as having produced on the latest roll. */
|
/** Hexes to light up as having produced on the latest roll. */
|
||||||
rolledHexes: Set<Int> = emptySet(),
|
rolledHexes: Set<Int> = emptySet(),
|
||||||
|
/** Cities & Knights: the knight whose menu is open or that is about to move. */
|
||||||
|
selectedKnight: Int? = null,
|
||||||
) {
|
) {
|
||||||
val measurer = rememberTextMeasurer()
|
val measurer = rememberTextMeasurer()
|
||||||
var size by remember { mutableStateOf(IntSize.Zero) }
|
var size by remember { mutableStateOf(IntSize.Zero) }
|
||||||
@@ -86,8 +89,8 @@ fun CatanBoardView(
|
|||||||
val at = Pt(tap.x, tap.y)
|
val at = Pt(tap.x, tap.y)
|
||||||
val reach = board.radius * style.tapReach
|
val reach = board.radius * style.tapReach
|
||||||
when {
|
when {
|
||||||
targets.vertices.isNotEmpty() ->
|
targets.tappableVertices.isNotEmpty() ->
|
||||||
board.nearestVertex(at, reach, targets.vertices)?.let { vertexTap(it) }
|
board.nearestVertex(at, reach, targets.tappableVertices)?.let { vertexTap(it) }
|
||||||
|
|
||||||
targets.edges.isNotEmpty() ->
|
targets.edges.isNotEmpty() ->
|
||||||
board.nearestEdge(at, reach, targets.edges)?.let { edgeTap(it) }
|
board.nearestEdge(at, reach, targets.edges)?.let { edgeTap(it) }
|
||||||
@@ -105,10 +108,15 @@ fun CatanBoardView(
|
|||||||
drawHexes(state, board, style, measurer)
|
drawHexes(state, board, style, measurer)
|
||||||
drawRolled(rolledHexes, board, style)
|
drawRolled(rolledHexes, board, style)
|
||||||
drawRobber(state, board, style)
|
drawRobber(state, board, style)
|
||||||
|
drawMerchant(state, board, style, measurer)
|
||||||
drawRoads(state, board, style)
|
drawRoads(state, board, style)
|
||||||
drawEdgeTargets(targets, board, style)
|
drawEdgeTargets(targets, board, style)
|
||||||
drawBuildings(state, board, style)
|
drawBuildings(state, board, style)
|
||||||
drawVertexTargets(targets, selectedVertex, board, style)
|
drawWalls(state, board, style)
|
||||||
|
drawMetropolises(state, board, style)
|
||||||
|
drawKnights(state, board, style)
|
||||||
|
drawKnightTargets(targets, board, style)
|
||||||
|
drawVertexTargets(targets, selectedVertex ?: selectedKnight, board, style)
|
||||||
drawHexTargets(targets, board, style)
|
drawHexTargets(targets, board, style)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,6 +193,10 @@ private fun DrawScope.drawToken(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun DrawScope.drawRobber(state: CatanState, board: BoardLayout, style: CatanStyle) {
|
private fun DrawScope.drawRobber(state: CatanState, board: BoardLayout, style: CatanStyle) {
|
||||||
|
// In Cities & Knights the robber sleeps, drawn faintly, until the barbarians first land.
|
||||||
|
val asleep = state.ck?.attacked == false
|
||||||
|
val tint = if (asleep) style.robber.copy(alpha = 0.3f) else style.robber
|
||||||
|
val rim = Color.White.copy(alpha = if (asleep) 0.25f else 0.7f)
|
||||||
val hex = board.hexCenter(state.robber)
|
val hex = board.hexCenter(state.robber)
|
||||||
// Beside the number token rather than on it, so the number stays readable.
|
// Beside the number token rather than on it, so the number stays readable.
|
||||||
val x = hex.x + board.radius * 0.5f
|
val x = hex.x + board.radius * 0.5f
|
||||||
@@ -197,11 +209,11 @@ private fun DrawScope.drawRobber(state: CatanState, board: BoardLayout, style: C
|
|||||||
lineTo(x + s * 0.55f, y + s * 0.9f)
|
lineTo(x + s * 0.55f, y + s * 0.9f)
|
||||||
close()
|
close()
|
||||||
}
|
}
|
||||||
drawPath(pawn, color = style.robber)
|
drawPath(pawn, color = tint)
|
||||||
drawCircle(color = style.robber, radius = s * 0.38f, center = Offset(x, y - s * 0.42f))
|
drawCircle(color = tint, radius = s * 0.38f, center = Offset(x, y - s * 0.42f))
|
||||||
drawPath(pawn, color = Color.White.copy(alpha = 0.7f), style = Stroke(width = s * 0.07f))
|
drawPath(pawn, color = rim, style = Stroke(width = s * 0.07f))
|
||||||
drawCircle(
|
drawCircle(
|
||||||
color = Color.White.copy(alpha = 0.7f),
|
color = rim,
|
||||||
radius = s * 0.38f,
|
radius = s * 0.38f,
|
||||||
center = Offset(x, y - s * 0.42f),
|
center = Offset(x, y - s * 0.42f),
|
||||||
style = Stroke(width = s * 0.07f),
|
style = Stroke(width = s * 0.07f),
|
||||||
@@ -409,6 +421,131 @@ private fun cityPath(at: Pt, s: Float): Path = Path().apply {
|
|||||||
close()
|
close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cities & Knights pieces
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A dashed wall round each walled city, crenellated by the dashes. */
|
||||||
|
private fun DrawScope.drawWalls(state: CatanState, board: BoardLayout, style: CatanStyle) {
|
||||||
|
val walls = state.ck?.walls ?: return
|
||||||
|
for (vertex in walls) {
|
||||||
|
val at = board.vertex(vertex).offset()
|
||||||
|
val radius = board.radius * style.citySize * 1.25f
|
||||||
|
val dash = PathEffect.dashPathEffect(floatArrayOf(radius * 0.38f, radius * 0.22f))
|
||||||
|
drawCircle(
|
||||||
|
color = style.pieceOutline,
|
||||||
|
radius = radius,
|
||||||
|
center = at,
|
||||||
|
style = Stroke(width = board.radius * 0.13f, pathEffect = dash),
|
||||||
|
)
|
||||||
|
drawCircle(
|
||||||
|
color = style.wall,
|
||||||
|
radius = radius,
|
||||||
|
center = at,
|
||||||
|
style = Stroke(width = board.radius * 0.09f, pathEffect = dash),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pennant in the track's colour flying over a city that carries its metropolis. */
|
||||||
|
private fun DrawScope.drawMetropolises(state: CatanState, board: BoardLayout, style: CatanStyle) {
|
||||||
|
val metropolises = state.ck?.metropolises ?: return
|
||||||
|
for ((track, at) in metropolises) {
|
||||||
|
val base = board.vertex(at.vertex)
|
||||||
|
val unit = board.radius * style.citySize
|
||||||
|
val poleTop = Pt(base.x + unit * 0.9f, base.y - unit * 2.0f)
|
||||||
|
val poleBottom = Pt(base.x + unit * 0.9f, base.y - unit * 0.4f)
|
||||||
|
drawLine(
|
||||||
|
color = style.pieceOutline,
|
||||||
|
start = poleBottom.offset(),
|
||||||
|
end = poleTop.offset(),
|
||||||
|
strokeWidth = board.radius * 0.05f,
|
||||||
|
)
|
||||||
|
val flag = Path().apply {
|
||||||
|
moveTo(poleTop.x, poleTop.y)
|
||||||
|
lineTo(poleTop.x + unit * 1.1f, poleTop.y + unit * 0.35f)
|
||||||
|
lineTo(poleTop.x, poleTop.y + unit * 0.7f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
drawPath(flag, color = style.track.getValue(track))
|
||||||
|
drawPath(flag, color = style.pieceOutline, style = Stroke(width = board.radius * 0.035f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Each knight is a shield in its owner's colour, marked with one, two or three
|
||||||
|
* dots for its strength. A sleeping knight is drawn faded; an awake one has a
|
||||||
|
* bright rim.
|
||||||
|
*/
|
||||||
|
private fun DrawScope.drawKnights(state: CatanState, board: BoardLayout, style: CatanStyle) {
|
||||||
|
val knights = state.ck?.knights ?: return
|
||||||
|
for (knight in knights) {
|
||||||
|
val at = board.vertex(knight.vertex)
|
||||||
|
val s = board.radius * style.knightSize
|
||||||
|
val seat = style.seats[knight.owner % style.seats.size]
|
||||||
|
val path = shieldPath(at, s)
|
||||||
|
if (knight.active) {
|
||||||
|
drawPath(path, color = style.knightReady, style = Stroke(width = s * 0.55f))
|
||||||
|
}
|
||||||
|
drawPath(path, color = if (knight.active) seat else seat.copy(alpha = 0.55f))
|
||||||
|
drawPath(path, color = style.pieceOutline, style = Stroke(width = s * 0.16f))
|
||||||
|
val pip = s * 0.14f
|
||||||
|
val gap = pip * 2.8f
|
||||||
|
val startX = at.x - gap * (knight.level - 1) / 2f
|
||||||
|
for (i in 0 until knight.level) {
|
||||||
|
drawCircle(
|
||||||
|
color = style.pieceOutline,
|
||||||
|
radius = pip,
|
||||||
|
center = Offset(startX + i * gap, at.y - s * 0.05f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shieldPath(at: Pt, s: Float): Path = Path().apply {
|
||||||
|
moveTo(at.x - s * 0.8f, at.y - s)
|
||||||
|
lineTo(at.x + s * 0.8f, at.y - s)
|
||||||
|
lineTo(at.x + s * 0.8f, at.y + s * 0.1f)
|
||||||
|
quadraticBezierTo(at.x + s * 0.8f, at.y + s * 0.9f, at.x, at.y + s * 1.25f)
|
||||||
|
quadraticBezierTo(at.x - s * 0.8f, at.y + s * 0.9f, at.x - s * 0.8f, at.y + s * 0.1f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A ring round each of the player's knights that can do something now. */
|
||||||
|
private fun DrawScope.drawKnightTargets(targets: BoardTargets, board: BoardLayout, style: CatanStyle) {
|
||||||
|
for (vertex in targets.knights) {
|
||||||
|
drawCircle(
|
||||||
|
color = style.target,
|
||||||
|
radius = board.radius * style.knightSize * 1.9f,
|
||||||
|
center = board.vertex(vertex).offset(),
|
||||||
|
style = Stroke(width = board.radius * 0.07f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The merchant: a coin in its controller's colour, beside the hex it sits on. */
|
||||||
|
private fun DrawScope.drawMerchant(
|
||||||
|
state: CatanState,
|
||||||
|
board: BoardLayout,
|
||||||
|
style: CatanStyle,
|
||||||
|
measurer: TextMeasurer,
|
||||||
|
) {
|
||||||
|
val merchant = state.ck?.merchant ?: return
|
||||||
|
val hex = board.hexCenter(merchant.hex)
|
||||||
|
val at = Pt(hex.x - board.radius * 0.5f, hex.y + board.radius * 0.3f)
|
||||||
|
val r = board.radius * 0.2f
|
||||||
|
drawCircle(color = style.seats[merchant.seat % style.seats.size], radius = r, center = at.offset())
|
||||||
|
drawCircle(color = style.pieceOutline, radius = r, center = at.offset(), style = Stroke(width = r * 0.3f))
|
||||||
|
drawCentered(
|
||||||
|
measurer = measurer,
|
||||||
|
text = "M",
|
||||||
|
at = at,
|
||||||
|
fontPx = r * 1.3f,
|
||||||
|
color = style.pieceOutline,
|
||||||
|
weight = FontWeight.ExtraBold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tap targets
|
// Tap targets
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,728 @@
|
|||||||
|
package org.prolibertate.games.ui.game.catan
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
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.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.prolibertate.games.game.catan.ActivateKnight
|
||||||
|
import org.prolibertate.games.game.catan.BuyImprovement
|
||||||
|
import org.prolibertate.games.game.catan.CatanCk
|
||||||
|
import org.prolibertate.games.game.catan.CatanMove
|
||||||
|
import org.prolibertate.games.game.catan.CatanState
|
||||||
|
import org.prolibertate.games.game.catan.ChaseRobber
|
||||||
|
import org.prolibertate.games.game.catan.DropProgress
|
||||||
|
import org.prolibertate.games.game.catan.GoodsBankTrade
|
||||||
|
import org.prolibertate.games.game.catan.Good
|
||||||
|
import org.prolibertate.games.game.catan.GoodsOffer
|
||||||
|
import org.prolibertate.games.game.catan.IMPROVEMENT_MAX
|
||||||
|
import org.prolibertate.games.game.catan.MoveKnight
|
||||||
|
import org.prolibertate.games.game.catan.OfferGoods
|
||||||
|
import org.prolibertate.games.game.catan.PickDeck
|
||||||
|
import org.prolibertate.games.game.catan.PlayProgress
|
||||||
|
import org.prolibertate.games.game.catan.ProgressCard
|
||||||
|
import org.prolibertate.games.game.catan.ProgressType
|
||||||
|
import org.prolibertate.games.game.catan.PromoteKnight
|
||||||
|
import org.prolibertate.games.game.catan.SpyTake
|
||||||
|
import org.prolibertate.games.game.catan.TakeAqueduct
|
||||||
|
import org.prolibertate.games.game.catan.Track
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The Cities & Knights dialogs. Like the base game's, each is a stateless
|
||||||
|
* composable apart from the choice it is helping the player make, and what is
|
||||||
|
* allowed is always asked of the engine's legal list through CatanInteraction.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared pieces
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A card tile with a minus and a plus, for choosing how many of a good. */
|
||||||
|
@Composable
|
||||||
|
private fun GoodCounter(good: Good, count: Int, max: Int, onChange: (Int) -> Unit) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
GoodTile(good, count, enabled = max > 0 || count > 0)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { onChange(count - 1) },
|
||||||
|
enabled = count > 0,
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
modifier = Modifier.width(34.dp).height(32.dp),
|
||||||
|
) { Text("−") }
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { onChange(count + 1) },
|
||||||
|
enabled = count < max,
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
modifier = Modifier.width(34.dp).height(32.dp),
|
||||||
|
) { Text("+") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Map<Good, Int>.asGoods(): List<Good> = Good.entries.flatMap { g -> List(this[g] ?: 0) { g } }
|
||||||
|
|
||||||
|
/** A row of the three track colours, used for decks and improvements. */
|
||||||
|
@Composable
|
||||||
|
private fun TrackChip(track: Track, style: CatanStyle = LocalCatanStyle.current) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(14.dp)
|
||||||
|
.clip(RoundedCornerShape(3.dp))
|
||||||
|
.background(style.track.getValue(track)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Choosing cards to give up
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick exactly [quota] cards out of [held] — to discard on a seven or a saboteur,
|
||||||
|
* or to hand over for a wedding or a commercial harbour. Not dismissable: the
|
||||||
|
* game is waiting on it. [allowed] is the engine's structural check, so the
|
||||||
|
* confirm button only lights when the move would be accepted.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun CatanGoodsPickDialog(
|
||||||
|
title: String,
|
||||||
|
intro: String,
|
||||||
|
held: Map<Good, Int>,
|
||||||
|
quota: Int,
|
||||||
|
confirmLabel: String,
|
||||||
|
selectable: (Good) -> Boolean,
|
||||||
|
allowed: (List<Good>) -> Boolean,
|
||||||
|
onConfirm: (List<Good>) -> Unit,
|
||||||
|
) {
|
||||||
|
var picked by remember { mutableStateOf(emptyMap<Good, Int>()) }
|
||||||
|
val selection = picked.asGoods()
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {},
|
||||||
|
title = { Text(title) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text("$intro (${selection.size} of $quota chosen).")
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Good.entries.forEach { good ->
|
||||||
|
val have = held[good] ?: 0
|
||||||
|
if (have > 0 && selectable(good)) {
|
||||||
|
GoodCounter(
|
||||||
|
good = good,
|
||||||
|
count = picked[good] ?: 0,
|
||||||
|
max = if (selection.size < quota) have else (picked[good] ?: 0),
|
||||||
|
onChange = { n -> picked = picked + (good to n.coerceIn(0, have)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(onClick = { onConfirm(selection) }, enabled = allowed(selection)) { Text(confirmLabel) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// City improvements
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** The three improvement tracks: where the player stands, what the next level costs, and what it earns. */
|
||||||
|
@Composable
|
||||||
|
fun CatanImproveDialog(
|
||||||
|
state: CatanState,
|
||||||
|
seat: Int,
|
||||||
|
legal: List<CatanMove>,
|
||||||
|
onBuy: (BuyImprovement) -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
) {
|
||||||
|
val ck = state.ck ?: return
|
||||||
|
val buyable = CatanInteraction.improvable(legal)
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onCancel,
|
||||||
|
title = { Text(CatanText.IMPROVE) },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
Track.entries.forEach { track ->
|
||||||
|
val level = CatanCk.improvement(state, seat, track)
|
||||||
|
val have = ck.commodities[seat][track.commodity] ?: 0
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
TrackChip(track)
|
||||||
|
Text(CatanText.track(track), fontWeight = FontWeight.Bold)
|
||||||
|
Text("level $level of $IMPROVEMENT_MAX", style = MaterialTheme.typography.labelMedium)
|
||||||
|
}
|
||||||
|
Text(CatanText.trackPower(track), style = MaterialTheme.typography.bodySmall)
|
||||||
|
if (level >= IMPROVEMENT_MAX) {
|
||||||
|
Text("Fully built.", style = MaterialTheme.typography.bodySmall)
|
||||||
|
} else {
|
||||||
|
val cost = CatanCk.improvementCost(state, seat, track)
|
||||||
|
Button(onClick = { onBuy(BuyImprovement(track)) }, enabled = track in buyable) {
|
||||||
|
Text(
|
||||||
|
"${CatanCk.improvementName(track, level + 1)}: $cost " +
|
||||||
|
"${CatanText.good(Good.of(track.commodity))} (you hold $have)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (level + 1 == org.prolibertate.games.game.catan.METROPOLIS_LEVEL ||
|
||||||
|
level + 1 == IMPROVEMENT_MAX
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (level + 1 == IMPROVEMENT_MAX) {
|
||||||
|
"Level five takes a metropolis from a seat still on four."
|
||||||
|
} else {
|
||||||
|
"Level four earns the first metropolis in this track: two more points."
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
dismissButton = { TextButton(onClick = onCancel) { Text("Close") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Progress cards
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player's progress cards. A card with nothing to decide plays at once; one
|
||||||
|
* that names a seat, a card or dice asks for it in a second step; one that
|
||||||
|
* needs a tap on the board closes the dialog and arms the board (see [ArmedCard]).
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun CatanProgressDialog(
|
||||||
|
hand: List<ProgressInHand>,
|
||||||
|
seats: CatanSeats,
|
||||||
|
onPlay: (CatanMove) -> Unit,
|
||||||
|
onArm: (ArmedCard) -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
) {
|
||||||
|
var chosen by remember { mutableStateOf<ProgressInHand?>(null) }
|
||||||
|
var target by remember { mutableStateOf<Int?>(null) }
|
||||||
|
var yellow by remember { mutableStateOf(1) }
|
||||||
|
var red by remember { mutableStateOf(1) }
|
||||||
|
val entry = chosen
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onCancel,
|
||||||
|
title = { Text(entry?.let { CatanText.progress(it.card.kind) } ?: "Progress cards") },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.heightIn(max = 440.dp).verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
if (entry == null) {
|
||||||
|
if (hand.isEmpty()) Text("You hold no progress cards.")
|
||||||
|
hand.forEach { card ->
|
||||||
|
ProgressRow(card) { picked ->
|
||||||
|
val kind = picked.card.kind
|
||||||
|
when {
|
||||||
|
kind in CatanInteraction.BOARD_CARDS -> onArm(ArmedCard(kind, picked.plays))
|
||||||
|
picked.plays.size == 1 && picked.plays[0].isBare() -> onPlay(picked.plays[0])
|
||||||
|
else -> {
|
||||||
|
chosen = picked
|
||||||
|
target = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(CatanText.progressHelp(entry.card.kind), style = MaterialTheme.typography.bodySmall)
|
||||||
|
val kind = entry.card.kind
|
||||||
|
val plays = entry.plays
|
||||||
|
when {
|
||||||
|
kind == ProgressType.ALCHEMIST -> {
|
||||||
|
Text("Production die (yellow)", fontWeight = FontWeight.Bold)
|
||||||
|
DiePicker(yellow) { yellow = it }
|
||||||
|
Text("Progress die (red)", fontWeight = FontWeight.Bold)
|
||||||
|
DiePicker(red) { red = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
kind == ProgressType.DESERTER || kind == ProgressType.SPY -> {
|
||||||
|
Text("Choose a player:", fontWeight = FontWeight.Bold)
|
||||||
|
plays.mapNotNull { it.target }.distinct().forEach { seat ->
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { plays.firstOrNull { it.target == seat }?.let(onPlay) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text(seats.label(seat)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kind == ProgressType.MASTER_MERCHANT -> {
|
||||||
|
Text("Choose a player with more points:", fontWeight = FontWeight.Bold)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
plays.mapNotNull { it.target }.distinct().forEach { seat ->
|
||||||
|
if (target == seat) {
|
||||||
|
FilledTonalButton(onClick = { target = seat }) { Text(seats.label(seat)) }
|
||||||
|
} else {
|
||||||
|
OutlinedButton(onClick = { target = seat }) { Text(seats.label(seat)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val seat = target
|
||||||
|
if (seat != null) {
|
||||||
|
Text("Take:", fontWeight = FontWeight.Bold)
|
||||||
|
plays.filter { it.target == seat }.forEach { play ->
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { onPlay(play) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text(CatanText.goods(play.goods.orEmpty())) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
val label = when (kind) {
|
||||||
|
ProgressType.COMMERCIAL_HARBOR -> "Offer which resource?"
|
||||||
|
ProgressType.MERCHANT_FLEET -> "Trade which card two for one?"
|
||||||
|
ProgressType.RESOURCE_MONOPOLY -> "Name a resource:"
|
||||||
|
else -> "Name a commodity:"
|
||||||
|
}
|
||||||
|
Text(label, fontWeight = FontWeight.Bold)
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
plays.mapNotNull { it.good }.distinct().forEach { good ->
|
||||||
|
GoodTile(
|
||||||
|
good = good,
|
||||||
|
count = 0,
|
||||||
|
onClick = { plays.firstOrNull { it.good == good }?.let(onPlay) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
if (entry != null && entry.card.kind == ProgressType.ALCHEMIST) {
|
||||||
|
val play = entry.plays.firstOrNull { it.dice == listOf(yellow, red) }
|
||||||
|
Button(onClick = { play?.let(onPlay) }, enabled = play != null) { Text("Play") }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
if (entry == null) {
|
||||||
|
onCancel()
|
||||||
|
} else {
|
||||||
|
chosen = null
|
||||||
|
target = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { Text(if (entry == null) CatanText.CANCEL else "Back") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun PlayProgress.isBare(): Boolean =
|
||||||
|
target == null && vertex == null && edge == null && hexA == null && hexB == null &&
|
||||||
|
good == null && dice == null && goods == null
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DiePicker(selected: Int, onPick: (Int) -> Unit) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
(1..6).forEach { n ->
|
||||||
|
if (n == selected) {
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { onPick(n) },
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
modifier = Modifier.size(40.dp),
|
||||||
|
) { Text("$n") }
|
||||||
|
} else {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { onPick(n) },
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
modifier = Modifier.size(40.dp),
|
||||||
|
) { Text("$n") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProgressRow(entry: ProgressInHand, onChoose: (ProgressInHand) -> Unit) {
|
||||||
|
val kind = entry.card.kind
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
TrackChip(kind.track)
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(CatanText.progress(kind), fontWeight = FontWeight.Bold)
|
||||||
|
Text(CatanText.progressHelp(kind), style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
if (entry.playable) {
|
||||||
|
Button(onClick = { onChoose(entry) }) { Text("Play") }
|
||||||
|
} else {
|
||||||
|
Text("Not now", style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Knights
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** What the knight on one crossing can do: wake, promote, move, chase the robber. */
|
||||||
|
@Composable
|
||||||
|
fun CatanKnightDialog(
|
||||||
|
state: CatanState,
|
||||||
|
vertex: Int,
|
||||||
|
actions: List<CatanMove>,
|
||||||
|
onDo: (CatanMove) -> Unit,
|
||||||
|
onMove: (Int) -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
) {
|
||||||
|
val knight = state.ck?.knights?.firstOrNull { it.vertex == vertex }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onCancel,
|
||||||
|
title = { Text(knight?.let { CatanText.knightName(it.level) } ?: "Knight") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
if (knight != null) {
|
||||||
|
val mood = when {
|
||||||
|
!knight.active -> "Asleep. Waking it costs ${CatanText.ACTIVATE_COST}."
|
||||||
|
knight.activatedTurn == state.turnNumber -> "Awake, but it woke this turn and cannot act yet."
|
||||||
|
else -> "Awake and ready. It counts against the barbarians."
|
||||||
|
}
|
||||||
|
Text(mood, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
actions.forEach { move ->
|
||||||
|
when (move) {
|
||||||
|
is ActivateKnight ->
|
||||||
|
Button(onClick = { onDo(move) }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Wake (${CatanText.ACTIVATE_COST})")
|
||||||
|
}
|
||||||
|
|
||||||
|
is PromoteKnight ->
|
||||||
|
Button(onClick = { onDo(move) }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Promote (${CatanText.KNIGHT_COST})")
|
||||||
|
}
|
||||||
|
|
||||||
|
is MoveKnight ->
|
||||||
|
OutlinedButton(onClick = { onMove(move.from) }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Move along your roads")
|
||||||
|
}
|
||||||
|
|
||||||
|
is ChaseRobber ->
|
||||||
|
OutlinedButton(onClick = { onDo(move) }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Chase away the robber")
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (actions.isEmpty()) Text("Nothing to do with it right now.")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
dismissButton = { TextButton(onClick = onCancel) { Text("Close") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Chores that are answered in a dialog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Choose a colour of progress deck to draw from. */
|
||||||
|
@Composable
|
||||||
|
fun CatanPickDeckDialog(legal: List<CatanMove>, onPick: (PickDeck) -> Unit) {
|
||||||
|
val open = legal.filterIsInstance<PickDeck>()
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {},
|
||||||
|
title = { Text("Draw a progress card") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("Catan held. You tied for the strongest defence, so choose any deck.")
|
||||||
|
open.forEach { pick ->
|
||||||
|
Button(onClick = { onPick(pick) }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
TrackChip(pick.track)
|
||||||
|
Text(" ${CatanText.track(pick.track)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The hand is over its limit: give one progress card back. */
|
||||||
|
@Composable
|
||||||
|
fun CatanDropProgressDialog(hand: List<ProgressCard>, onDrop: (DropProgress) -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {},
|
||||||
|
title = { Text("Too many progress cards") },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.heightIn(max = 380.dp).verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text("You may keep four. Choose one to give back.")
|
||||||
|
hand.forEach { card ->
|
||||||
|
OutlinedButton(onClick = { onDrop(DropProgress(card.id)) }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
TrackChip(card.kind.track)
|
||||||
|
Text(" ${CatanText.progress(card.kind)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The aqueduct pays: take any one resource. */
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun CatanAqueductDialog(legal: List<CatanMove>, onTake: (TakeAqueduct) -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {},
|
||||||
|
title = { Text("Aqueduct") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text("The dice paid you nothing. Take any resource.")
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
legal.filterIsInstance<TakeAqueduct>().forEach { take ->
|
||||||
|
ResourceTile(resource = take.resource, count = 1, onClick = { onTake(take) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A spy: these are the progress cards the other player holds; take one. */
|
||||||
|
@Composable
|
||||||
|
fun CatanSpyDialog(cards: List<ProgressCard>, legal: List<CatanMove>, onTake: (SpyTake) -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {},
|
||||||
|
title = { Text("Spy") },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.heightIn(max = 380.dp).verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text("Choose a card to take.")
|
||||||
|
cards.forEach { card ->
|
||||||
|
val take = SpyTake(card.id)
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { onTake(take) },
|
||||||
|
enabled = take in legal,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
TrackChip(card.kind.track)
|
||||||
|
Text(" ${CatanText.progress(card.kind)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Trading in goods
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private enum class GoodsTab { BANK, PLAYER }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trade with the bank at the best ratio each card has, or offer cards —
|
||||||
|
* commodities included — to one other player.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun CatanGoodsTradeDialog(
|
||||||
|
state: CatanState,
|
||||||
|
seat: Int,
|
||||||
|
seats: CatanSeats,
|
||||||
|
legal: List<CatanMove>,
|
||||||
|
onBank: (GoodsBankTrade) -> Unit,
|
||||||
|
onOffer: (OfferGoods) -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
) {
|
||||||
|
var tab by remember { mutableStateOf(GoodsTab.BANK) }
|
||||||
|
var bankGive by remember { mutableStateOf<Good?>(null) }
|
||||||
|
var partner by remember { mutableStateOf<Int?>(null) }
|
||||||
|
var give by remember { mutableStateOf(emptyMap<Good, Int>()) }
|
||||||
|
var get by remember { mutableStateOf(emptyMap<Good, Int>()) }
|
||||||
|
val held = CatanCk.goodsOf(state, seat)
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onCancel,
|
||||||
|
title = { Text(CatanText.TRADE) },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
GoodsTabButton(CatanText.TRADE_WITH_BANK, tab == GoodsTab.BANK) { tab = GoodsTab.BANK }
|
||||||
|
GoodsTabButton(CatanText.TRADE_WITH_PLAYER, tab == GoodsTab.PLAYER) { tab = GoodsTab.PLAYER }
|
||||||
|
}
|
||||||
|
if (tab == GoodsTab.BANK) {
|
||||||
|
val trades = CatanInteraction.goodsBankTrades(legal)
|
||||||
|
if (trades.isEmpty()) {
|
||||||
|
Text("You do not hold enough of any one card to trade with the bank.")
|
||||||
|
} else {
|
||||||
|
Text("Give:", fontWeight = FontWeight.Bold)
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
trades.keys.forEach { good ->
|
||||||
|
val ratio = CatanCk.bankRatio(state, seat, good)
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
GoodTile(good, ratio, selected = bankGive == good, onClick = { bankGive = good })
|
||||||
|
Text("$ratio : 1", style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val chosen = bankGive
|
||||||
|
if (chosen != null && chosen in trades) {
|
||||||
|
Text("Get one:", fontWeight = FontWeight.Bold)
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
trades.getValue(chosen).forEach { good ->
|
||||||
|
GoodTile(good, 1, onClick = { onBank(GoodsBankTrade(chosen, good)) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Offer to:", fontWeight = FontWeight.Bold)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
(0 until state.playerCount).filter { it != seat }.forEach { other ->
|
||||||
|
GoodsTabButton(seats.label(other), partner == other) { partner = other }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(CatanText.YOU_GIVE, fontWeight = FontWeight.Bold)
|
||||||
|
GoodCounters(give, { held[it] ?: 0 }) { give = it }
|
||||||
|
Text(CatanText.YOU_GET, fontWeight = FontWeight.Bold)
|
||||||
|
GoodCounters(get, { 9 }) { get = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
if (tab == GoodsTab.PLAYER) {
|
||||||
|
val to = partner
|
||||||
|
val giveCards = give.asGoods()
|
||||||
|
val getCards = get.asGoods()
|
||||||
|
val allowed = to != null && CatanInteraction.goodsOfferAllowed(state, seat, to, giveCards, getCards)
|
||||||
|
Button(
|
||||||
|
onClick = { if (to != null) onOffer(OfferGoods(to, giveCards, getCards)) },
|
||||||
|
enabled = allowed,
|
||||||
|
) { Text(CatanText.SEND_OFFER) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = onCancel) { Text(CatanText.CANCEL) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun GoodCounters(counts: Map<Good, Int>, limit: (Good) -> Int, onChange: (Map<Good, Int>) -> Unit) {
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Good.entries.forEach { good ->
|
||||||
|
GoodCounter(
|
||||||
|
good = good,
|
||||||
|
count = counts[good] ?: 0,
|
||||||
|
max = limit(good),
|
||||||
|
onChange = { n -> onChange(counts + (good to n.coerceIn(0, limit(good)))) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GoodsTabButton(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||||
|
if (selected) {
|
||||||
|
FilledTonalButton(onClick = onClick) { Text(label) }
|
||||||
|
} else {
|
||||||
|
OutlinedButton(onClick = onClick) { Text(label) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Another player has offered the local player a trade in goods. */
|
||||||
|
@Composable
|
||||||
|
fun CatanGoodsTradeResponseDialog(
|
||||||
|
offer: GoodsOffer,
|
||||||
|
seats: CatanSeats,
|
||||||
|
canAccept: Boolean,
|
||||||
|
onAccept: () -> Unit,
|
||||||
|
onDecline: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {},
|
||||||
|
title = { Text("Trade offer") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Text(
|
||||||
|
"${seats.label(offer.by)} offers ${CatanText.goods(offer.give)} to you " +
|
||||||
|
"for ${CatanText.goods(offer.get)}",
|
||||||
|
)
|
||||||
|
if (!canAccept) {
|
||||||
|
Text(
|
||||||
|
"You cannot accept this: one side no longer holds the cards.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { Button(onClick = onAccept, enabled = canAccept) { Text(CatanText.ACCEPT) } },
|
||||||
|
dismissButton = { TextButton(onClick = onDecline) { Text(CatanText.DECLINE) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,43 +1,93 @@
|
|||||||
package org.prolibertate.games.ui.game.catan
|
package org.prolibertate.games.ui.game.catan
|
||||||
|
|
||||||
import org.prolibertate.games.game.catan.AcceptTrade
|
import org.prolibertate.games.game.catan.AcceptTrade
|
||||||
|
import org.prolibertate.games.game.catan.ActivateKnight
|
||||||
import org.prolibertate.games.game.catan.BuildCity
|
import org.prolibertate.games.game.catan.BuildCity
|
||||||
import org.prolibertate.games.game.catan.BuildRoad
|
import org.prolibertate.games.game.catan.BuildRoad
|
||||||
import org.prolibertate.games.game.catan.BuildSettlement
|
import org.prolibertate.games.game.catan.BuildSettlement
|
||||||
|
import org.prolibertate.games.game.catan.BuildWall
|
||||||
import org.prolibertate.games.game.catan.BuyDevCard
|
import org.prolibertate.games.game.catan.BuyDevCard
|
||||||
|
import org.prolibertate.games.game.catan.BuyImprovement
|
||||||
import org.prolibertate.games.game.catan.CatanBoard
|
import org.prolibertate.games.game.catan.CatanBoard
|
||||||
import org.prolibertate.games.game.catan.CatanMove
|
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.ChaseRobber
|
||||||
|
import org.prolibertate.games.game.catan.Chore
|
||||||
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
|
||||||
import org.prolibertate.games.game.catan.Discard
|
import org.prolibertate.games.game.catan.Discard
|
||||||
import org.prolibertate.games.game.catan.EndFreeRoads
|
import org.prolibertate.games.game.catan.EndFreeRoads
|
||||||
|
import org.prolibertate.games.game.catan.EndSmith
|
||||||
import org.prolibertate.games.game.catan.EndTurn
|
import org.prolibertate.games.game.catan.EndTurn
|
||||||
|
import org.prolibertate.games.game.catan.GoodsBankTrade
|
||||||
|
import org.prolibertate.games.game.catan.Good
|
||||||
import org.prolibertate.games.game.catan.HexCoord
|
import org.prolibertate.games.game.catan.HexCoord
|
||||||
|
import org.prolibertate.games.game.catan.HireKnight
|
||||||
import org.prolibertate.games.game.catan.MaritimeTrade
|
import org.prolibertate.games.game.catan.MaritimeTrade
|
||||||
|
import org.prolibertate.games.game.catan.MoveKnight
|
||||||
import org.prolibertate.games.game.catan.MoveRobber
|
import org.prolibertate.games.game.catan.MoveRobber
|
||||||
|
import org.prolibertate.games.game.catan.OfferGoods
|
||||||
import org.prolibertate.games.game.catan.OfferTrade
|
import org.prolibertate.games.game.catan.OfferTrade
|
||||||
|
import org.prolibertate.games.game.catan.PillageCity
|
||||||
|
import org.prolibertate.games.game.catan.PlaceKnight
|
||||||
|
import org.prolibertate.games.game.catan.PlaceMetropolis
|
||||||
import org.prolibertate.games.game.catan.PlayKnight
|
import org.prolibertate.games.game.catan.PlayKnight
|
||||||
import org.prolibertate.games.game.catan.PlayMonopoly
|
import org.prolibertate.games.game.catan.PlayMonopoly
|
||||||
|
import org.prolibertate.games.game.catan.PlayProgress
|
||||||
import org.prolibertate.games.game.catan.PlayRoadBuilding
|
import org.prolibertate.games.game.catan.PlayRoadBuilding
|
||||||
import org.prolibertate.games.game.catan.PlayYearOfPlenty
|
import org.prolibertate.games.game.catan.PlayYearOfPlenty
|
||||||
|
import org.prolibertate.games.game.catan.ProgressCard
|
||||||
|
import org.prolibertate.games.game.catan.ProgressType
|
||||||
|
import org.prolibertate.games.game.catan.PromoteKnight
|
||||||
|
import org.prolibertate.games.game.catan.RelocateKnight
|
||||||
|
import org.prolibertate.games.game.catan.RemoveKnight
|
||||||
import org.prolibertate.games.game.catan.Resource
|
import org.prolibertate.games.game.catan.Resource
|
||||||
import org.prolibertate.games.game.catan.RollDice
|
import org.prolibertate.games.game.catan.RollDice
|
||||||
import org.prolibertate.games.game.catan.SetupPlace
|
import org.prolibertate.games.game.catan.SetupPlace
|
||||||
|
import org.prolibertate.games.game.catan.Track
|
||||||
|
|
||||||
/** What a player can ask to build by tapping the board. */
|
/**
|
||||||
enum class BuildKind { ROAD, SETTLEMENT, CITY }
|
* What a player can ask to build by tapping the board. Wall and Knight exist
|
||||||
|
* only in Cities & Knights; the base game's build menu lists the first three.
|
||||||
|
*/
|
||||||
|
enum class BuildKind {
|
||||||
|
ROAD, SETTLEMENT, CITY, WALL, KNIGHT;
|
||||||
|
|
||||||
/** The parts of the board the player may tap right now. Everything else is inert. */
|
companion object {
|
||||||
|
val BASE: List<BuildKind> = listOf(ROAD, SETTLEMENT, CITY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The parts of the board the player may tap right now. Everything else is inert.
|
||||||
|
* [knights] are crossings holding one of the player's own knights that can do
|
||||||
|
* something this turn: tapping one opens its menu.
|
||||||
|
*/
|
||||||
data class BoardTargets(
|
data class BoardTargets(
|
||||||
val vertices: Set<Int> = emptySet(),
|
val vertices: Set<Int> = emptySet(),
|
||||||
val edges: Set<Int> = emptySet(),
|
val edges: Set<Int> = emptySet(),
|
||||||
val hexes: Set<Int> = emptySet(),
|
val hexes: Set<Int> = emptySet(),
|
||||||
|
val knights: Set<Int> = emptySet(),
|
||||||
) {
|
) {
|
||||||
val isEmpty: Boolean get() = vertices.isEmpty() && edges.isEmpty() && hexes.isEmpty()
|
val isEmpty: Boolean get() = vertices.isEmpty() && edges.isEmpty() && hexes.isEmpty() && knights.isEmpty()
|
||||||
|
|
||||||
|
/** Every crossing a tap can land on. */
|
||||||
|
val tappableVertices: Set<Int> get() = vertices + knights
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A progress card that is waiting for a tap on the board: [plays] are every play
|
||||||
|
* of it the engine accepts, and the player's tap picks one. [firstHex] is the
|
||||||
|
* first of the two hexes an Inventor swaps, once tapped.
|
||||||
|
*/
|
||||||
|
data class ArmedCard(val kind: ProgressType, val plays: List<PlayProgress>, val firstHex: Int? = null)
|
||||||
|
|
||||||
|
/** One progress card in the local player's hand, and the plays the engine allows for it. */
|
||||||
|
data class ProgressInHand(val card: ProgressCard, val plays: List<PlayProgress>) {
|
||||||
|
val playable: Boolean get() = plays.isNotEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What a tap on the board amounts to. */
|
/** What a tap on the board amounts to. */
|
||||||
@@ -53,6 +103,12 @@ sealed interface TapOutcome {
|
|||||||
|
|
||||||
/** The robber's hex has more than one possible victim: ask which. */
|
/** The robber's hex has more than one possible victim: ask which. */
|
||||||
data class PickRobberVictim(val hex: HexCoord, val victims: List<Int>) : TapOutcome
|
data class PickRobberVictim(val hex: HexCoord, val victims: List<Int>) : TapOutcome
|
||||||
|
|
||||||
|
/** One of the player's own knights was tapped: open its menu. */
|
||||||
|
data class PickKnight(val vertex: Int) : TapOutcome
|
||||||
|
|
||||||
|
/** An Inventor's first hex was tapped; the second completes the swap. */
|
||||||
|
data class PickFirstHex(val hex: Int) : TapOutcome
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,6 +141,8 @@ object CatanInteraction {
|
|||||||
legal: List<CatanMove>,
|
legal: List<CatanMove>,
|
||||||
building: BuildKind?,
|
building: BuildKind?,
|
||||||
setupVertex: Int?,
|
setupVertex: Int?,
|
||||||
|
movingKnight: Int? = null,
|
||||||
|
armed: ArmedCard? = null,
|
||||||
): BoardTargets {
|
): BoardTargets {
|
||||||
val setup = legal.filterIsInstance<SetupPlace>()
|
val setup = legal.filterIsInstance<SetupPlace>()
|
||||||
if (setup.isNotEmpty()) {
|
if (setup.isNotEmpty()) {
|
||||||
@@ -100,9 +158,21 @@ object CatanInteraction {
|
|||||||
return BoardTargets(hexes = robber.mapNotNull { CatanBoard.hexIndex[it.hex] }.toSet())
|
return BoardTargets(hexes = robber.mapNotNull { CatanBoard.hexIndex[it.hex] }.toSet())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cities & Knights: answers to a queued chore are taps on the board.
|
||||||
|
val chore = choreVertices(legal)
|
||||||
|
if (chore.isNotEmpty()) return BoardTargets(vertices = chore)
|
||||||
|
|
||||||
val roads = legal.filterIsInstance<BuildRoad>().map { it.edge }.toSet()
|
val roads = legal.filterIsInstance<BuildRoad>().map { it.edge }.toSet()
|
||||||
if (state.phase == CatanPhase.FREE_ROADS) return BoardTargets(edges = roads)
|
if (state.phase == CatanPhase.FREE_ROADS) return BoardTargets(edges = roads)
|
||||||
|
|
||||||
|
if (armed != null) return armedTargets(armed)
|
||||||
|
|
||||||
|
if (movingKnight != null) {
|
||||||
|
return BoardTargets(
|
||||||
|
vertices = legal.filterIsInstance<MoveKnight>().filter { it.from == movingKnight }.map { it.to }.toSet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return when (building) {
|
return when (building) {
|
||||||
BuildKind.ROAD -> BoardTargets(edges = roads)
|
BuildKind.ROAD -> BoardTargets(edges = roads)
|
||||||
BuildKind.SETTLEMENT ->
|
BuildKind.SETTLEMENT ->
|
||||||
@@ -111,15 +181,148 @@ object CatanInteraction {
|
|||||||
BuildKind.CITY ->
|
BuildKind.CITY ->
|
||||||
BoardTargets(vertices = legal.filterIsInstance<BuildCity>().map { it.vertex }.toSet())
|
BoardTargets(vertices = legal.filterIsInstance<BuildCity>().map { it.vertex }.toSet())
|
||||||
|
|
||||||
null -> BoardTargets()
|
BuildKind.WALL ->
|
||||||
|
BoardTargets(vertices = legal.filterIsInstance<BuildWall>().map { it.vertex }.toSet())
|
||||||
|
|
||||||
|
BuildKind.KNIGHT ->
|
||||||
|
BoardTargets(vertices = legal.filterIsInstance<HireKnight>().map { it.vertex }.toSet())
|
||||||
|
|
||||||
|
null -> BoardTargets(knights = knightsWithActions(legal))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The crossings a queued chore is asking the player to tap, from the legal list. */
|
||||||
|
private fun choreVertices(legal: List<CatanMove>): Set<Int> = buildSet {
|
||||||
|
legal.forEach { move ->
|
||||||
|
when (move) {
|
||||||
|
is PillageCity -> add(move.vertex)
|
||||||
|
is PlaceMetropolis -> add(move.vertex)
|
||||||
|
is RelocateKnight -> add(move.to)
|
||||||
|
is RemoveKnight -> add(move.vertex)
|
||||||
|
is PlaceKnight -> add(move.vertex)
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A Smith's promotions are knights to tap, while the chore is open.
|
||||||
|
if (legal.any { it is EndSmith }) {
|
||||||
|
legal.filterIsInstance<PromoteKnight>().forEach { add(it.vertex) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun choreMove(legal: List<CatanMove>, vertex: Int): CatanMove? = legal.firstOrNull { move ->
|
||||||
|
when (move) {
|
||||||
|
is PillageCity -> move.vertex == vertex
|
||||||
|
is PlaceMetropolis -> move.vertex == vertex
|
||||||
|
is RelocateKnight -> move.to == vertex
|
||||||
|
is RemoveKnight -> move.vertex == vertex
|
||||||
|
is PlaceKnight -> move.vertex == vertex
|
||||||
|
is PromoteKnight -> legal.any { it is EndSmith } && move.vertex == vertex
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Cities & Knights: knights ---------------------------------------------
|
||||||
|
|
||||||
|
/** Crossings of the player's knights that have an action available. */
|
||||||
|
fun knightsWithActions(legal: List<CatanMove>): Set<Int> = buildSet {
|
||||||
|
legal.forEach { move ->
|
||||||
|
when (move) {
|
||||||
|
is ActivateKnight -> add(move.vertex)
|
||||||
|
is MoveKnight -> add(move.from)
|
||||||
|
is ChaseRobber -> add(move.vertex)
|
||||||
|
is PromoteKnight -> if (legal.none { it is EndSmith }) add(move.vertex)
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the knight on [vertex] can do right now, in the order its menu lists them. */
|
||||||
|
fun knightActions(legal: List<CatanMove>, vertex: Int): List<CatanMove> =
|
||||||
|
legal.filter { move ->
|
||||||
|
when (move) {
|
||||||
|
is ActivateKnight -> move.vertex == vertex
|
||||||
|
is PromoteKnight -> move.vertex == vertex && legal.none { it is EndSmith }
|
||||||
|
is ChaseRobber -> move.vertex == vertex
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
} + listOfNotNull(legal.firstOrNull { it is MoveKnight && it.from == vertex })
|
||||||
|
|
||||||
|
fun canEndSmith(legal: List<CatanMove>): Boolean = legal.any { it is EndSmith }
|
||||||
|
|
||||||
|
// --- Cities & Knights: progress cards --------------------------------------
|
||||||
|
|
||||||
|
/** [seat]'s progress cards, each with the plays the engine currently allows. */
|
||||||
|
fun progressHand(state: CatanState, seat: Int, legal: List<CatanMove>): List<ProgressInHand> =
|
||||||
|
state.ck?.progressHands?.getOrNull(seat).orEmpty().map { card ->
|
||||||
|
ProgressInHand(card, legal.filterIsInstance<PlayProgress>().filter { it.card == card.id })
|
||||||
|
}.sortedWith(compareBy({ it.card.kind.track.ordinal }, { it.card.kind.ordinal }, { it.card.id }))
|
||||||
|
|
||||||
|
/** The cards that are played by tapping the board, not by choosing in a dialog. */
|
||||||
|
val BOARD_CARDS: Set<ProgressType> = setOf(
|
||||||
|
ProgressType.ENGINEER, ProgressType.MEDICINE, ProgressType.MERCHANT,
|
||||||
|
ProgressType.DIPLOMAT, ProgressType.INTRIGUE, ProgressType.INVENTOR,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun armedTargets(armed: ArmedCard): BoardTargets = when (armed.kind) {
|
||||||
|
ProgressType.ENGINEER, ProgressType.MEDICINE, ProgressType.INTRIGUE ->
|
||||||
|
BoardTargets(vertices = armed.plays.mapNotNull { it.vertex }.toSet())
|
||||||
|
|
||||||
|
ProgressType.DIPLOMAT -> BoardTargets(edges = armed.plays.mapNotNull { it.edge }.toSet())
|
||||||
|
|
||||||
|
ProgressType.MERCHANT -> BoardTargets(hexes = armed.plays.mapNotNull { it.hexA }.toSet())
|
||||||
|
|
||||||
|
ProgressType.INVENTOR -> {
|
||||||
|
val first = armed.firstHex
|
||||||
|
val all = armed.plays.flatMap { listOfNotNull(it.hexA, it.hexB) }.toSet()
|
||||||
|
if (first == null) {
|
||||||
|
BoardTargets(hexes = all)
|
||||||
|
} else {
|
||||||
|
BoardTargets(
|
||||||
|
hexes = armed.plays
|
||||||
|
.filter { it.hexA == first || it.hexB == first }
|
||||||
|
.flatMap { listOfNotNull(it.hexA, it.hexB) }
|
||||||
|
.filter { it != first }
|
||||||
|
.toSet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> BoardTargets()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The improvement tracks the player can buy a level of right now. */
|
||||||
|
fun improvable(legal: List<CatanMove>): Set<Track> =
|
||||||
|
legal.filterIsInstance<BuyImprovement>().map { it.track }.toSet()
|
||||||
|
|
||||||
|
// --- Cities & Knights: goods ----------------------------------------------
|
||||||
|
|
||||||
|
/** Bank trades on offer in Cities & Knights: what can be given, and for what. */
|
||||||
|
fun goodsBankTrades(legal: List<CatanMove>): Map<Good, List<Good>> =
|
||||||
|
legal.filterIsInstance<GoodsBankTrade>()
|
||||||
|
.groupBy({ it.give }, { it.get })
|
||||||
|
.mapValues { (_, gets) -> gets.sortedBy { it.ordinal } }
|
||||||
|
.toSortedMap(compareBy<Good> { it.ordinal })
|
||||||
|
|
||||||
|
fun goodsOfferAllowed(
|
||||||
|
state: CatanState,
|
||||||
|
seat: Int,
|
||||||
|
to: Int,
|
||||||
|
give: List<Good>,
|
||||||
|
get: List<Good>,
|
||||||
|
): Boolean = give.isNotEmpty() && get.isNotEmpty() &&
|
||||||
|
CatanRules.isLegalMove(state, seat, OfferGoods(to, give, get))
|
||||||
|
|
||||||
|
/** The chore at the front of the queue, if the table is waiting on one. */
|
||||||
|
fun chore(state: CatanState): Chore? =
|
||||||
|
if (state.phase == CatanPhase.CHORE) state.ck?.chores?.firstOrNull() else null
|
||||||
|
|
||||||
fun onVertexTap(
|
fun onVertexTap(
|
||||||
vertex: Int,
|
vertex: Int,
|
||||||
legal: List<CatanMove>,
|
legal: List<CatanMove>,
|
||||||
building: BuildKind?,
|
building: BuildKind?,
|
||||||
setupVertex: Int?,
|
setupVertex: Int?,
|
||||||
|
movingKnight: Int? = null,
|
||||||
|
armed: ArmedCard? = null,
|
||||||
): TapOutcome {
|
): TapOutcome {
|
||||||
val setup = legal.filterIsInstance<SetupPlace>()
|
val setup = legal.filterIsInstance<SetupPlace>()
|
||||||
if (setup.isNotEmpty()) {
|
if (setup.isNotEmpty()) {
|
||||||
@@ -131,10 +334,27 @@ object CatanInteraction {
|
|||||||
TapOutcome.Ignored
|
TapOutcome.Ignored
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
choreMove(legal, vertex)?.let { return TapOutcome.Submit(it) }
|
||||||
|
|
||||||
|
if (armed != null) {
|
||||||
|
val play = armed.plays.firstOrNull { it.vertex == vertex } ?: return TapOutcome.Ignored
|
||||||
|
return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored
|
||||||
|
}
|
||||||
|
if (movingKnight != null) {
|
||||||
|
val move = MoveKnight(movingKnight, vertex)
|
||||||
|
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
|
||||||
|
}
|
||||||
|
|
||||||
val move: CatanMove = when (building) {
|
val move: CatanMove = when (building) {
|
||||||
BuildKind.SETTLEMENT -> BuildSettlement(vertex)
|
BuildKind.SETTLEMENT -> BuildSettlement(vertex)
|
||||||
BuildKind.CITY -> BuildCity(vertex)
|
BuildKind.CITY -> BuildCity(vertex)
|
||||||
else -> return TapOutcome.Ignored
|
BuildKind.WALL -> BuildWall(vertex)
|
||||||
|
BuildKind.KNIGHT -> HireKnight(vertex)
|
||||||
|
else -> return if (building == null && vertex in knightsWithActions(legal)) {
|
||||||
|
TapOutcome.PickKnight(vertex)
|
||||||
|
} else {
|
||||||
|
TapOutcome.Ignored
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
|
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
|
||||||
}
|
}
|
||||||
@@ -144,12 +364,17 @@ object CatanInteraction {
|
|||||||
legal: List<CatanMove>,
|
legal: List<CatanMove>,
|
||||||
building: BuildKind?,
|
building: BuildKind?,
|
||||||
setupVertex: Int?,
|
setupVertex: Int?,
|
||||||
|
armed: ArmedCard? = null,
|
||||||
): TapOutcome {
|
): TapOutcome {
|
||||||
if (legal.any { it is SetupPlace }) {
|
if (legal.any { it is SetupPlace }) {
|
||||||
if (setupVertex == null) return TapOutcome.Ignored
|
if (setupVertex == null) return TapOutcome.Ignored
|
||||||
val move = SetupPlace(setupVertex, edge)
|
val move = SetupPlace(setupVertex, edge)
|
||||||
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
|
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
|
||||||
}
|
}
|
||||||
|
if (armed != null) {
|
||||||
|
val play = armed.plays.firstOrNull { it.edge == edge } ?: return TapOutcome.Ignored
|
||||||
|
return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored
|
||||||
|
}
|
||||||
// Free roads need no build mode; a paid road does.
|
// Free roads need no build mode; a paid road does.
|
||||||
val canBuild = building == BuildKind.ROAD || legal.any { it is EndFreeRoads }
|
val canBuild = building == BuildKind.ROAD || legal.any { it is EndFreeRoads }
|
||||||
if (!canBuild) return TapOutcome.Ignored
|
if (!canBuild) return TapOutcome.Ignored
|
||||||
@@ -157,7 +382,8 @@ object CatanInteraction {
|
|||||||
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
|
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onHexTap(hex: Int, legal: List<CatanMove>): TapOutcome {
|
fun onHexTap(hex: Int, legal: List<CatanMove>, armed: ArmedCard? = null): TapOutcome {
|
||||||
|
if (armed != null) return onArmedHexTap(hex, legal, armed)
|
||||||
val coord = CatanBoard.HEXES.getOrNull(hex) ?: return TapOutcome.Ignored
|
val coord = CatanBoard.HEXES.getOrNull(hex) ?: return TapOutcome.Ignored
|
||||||
val moves = legal.filterIsInstance<MoveRobber>().filter { it.hex == coord }
|
val moves = legal.filterIsInstance<MoveRobber>().filter { it.hex == coord }
|
||||||
return when {
|
return when {
|
||||||
@@ -167,6 +393,22 @@ object CatanInteraction {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onArmedHexTap(hex: Int, legal: List<CatanMove>, armed: ArmedCard): TapOutcome {
|
||||||
|
if (armed.kind == ProgressType.INVENTOR) {
|
||||||
|
val first = armed.firstHex ?: return if (hex in armedTargets(armed).hexes) {
|
||||||
|
TapOutcome.PickFirstHex(hex)
|
||||||
|
} else {
|
||||||
|
TapOutcome.Ignored
|
||||||
|
}
|
||||||
|
val play = armed.plays.firstOrNull {
|
||||||
|
(it.hexA == first && it.hexB == hex) || (it.hexA == hex && it.hexB == first)
|
||||||
|
} ?: return TapOutcome.Ignored
|
||||||
|
return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored
|
||||||
|
}
|
||||||
|
val play = armed.plays.firstOrNull { it.hexA == hex } ?: return TapOutcome.Ignored
|
||||||
|
return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored
|
||||||
|
}
|
||||||
|
|
||||||
// --- Action bar ------------------------------------------------------------
|
// --- Action bar ------------------------------------------------------------
|
||||||
|
|
||||||
fun canRoll(legal: List<CatanMove>): Boolean = legal.any { it is RollDice }
|
fun canRoll(legal: List<CatanMove>): Boolean = legal.any { it is RollDice }
|
||||||
@@ -200,6 +442,8 @@ object CatanInteraction {
|
|||||||
if (legal.any { it is BuildRoad }) add(BuildKind.ROAD)
|
if (legal.any { it is BuildRoad }) add(BuildKind.ROAD)
|
||||||
if (legal.any { it is BuildSettlement }) add(BuildKind.SETTLEMENT)
|
if (legal.any { it is BuildSettlement }) add(BuildKind.SETTLEMENT)
|
||||||
if (legal.any { it is BuildCity }) add(BuildKind.CITY)
|
if (legal.any { it is BuildCity }) add(BuildKind.CITY)
|
||||||
|
if (legal.any { it is BuildWall }) add(BuildKind.WALL)
|
||||||
|
if (legal.any { it is HireKnight }) add(BuildKind.KNIGHT)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun tradeResponses(legal: List<CatanMove>): Pair<Boolean, Boolean> =
|
fun tradeResponses(legal: List<CatanMove>): Pair<Boolean, Boolean> =
|
||||||
|
|||||||
@@ -40,10 +40,15 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
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.BuildingType
|
||||||
|
import org.prolibertate.games.game.catan.CatanCk
|
||||||
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.EventFace
|
||||||
|
import org.prolibertate.games.game.catan.Good
|
||||||
import org.prolibertate.games.game.catan.Resource
|
import org.prolibertate.games.game.catan.Resource
|
||||||
|
import org.prolibertate.games.game.catan.Track
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Stateless building blocks for the Katan screen.
|
* Stateless building blocks for the Katan screen.
|
||||||
@@ -102,7 +107,52 @@ fun ResourceTile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The local player's cards: one tile per resource, in a row that wraps. */
|
/**
|
||||||
|
* One card type — a resource or a commodity — as a coloured tile with its count.
|
||||||
|
* Cities & Knights hands, discards and trades use this so commodities look the
|
||||||
|
* same everywhere.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GoodTile(
|
||||||
|
good: Good,
|
||||||
|
count: Int,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
selected: Boolean = false,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
style: CatanStyle = LocalCatanStyle.current,
|
||||||
|
onClick: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val resource = good.resource
|
||||||
|
if (resource != null) {
|
||||||
|
ResourceTile(resource, count, modifier, selected, enabled, style, onClick)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val commodity = good.commodity!!
|
||||||
|
val shape = RoundedCornerShape(10.dp)
|
||||||
|
val ink = style.commodityInk.getValue(commodity)
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.defaultMinSize(minWidth = 56.dp)
|
||||||
|
.clip(shape)
|
||||||
|
.background(style.commodity.getValue(commodity).copy(alpha = if (enabled) 1f else 0.4f))
|
||||||
|
.border(
|
||||||
|
width = if (selected) 3.dp else 1.dp,
|
||||||
|
color = if (selected) Color.White else Color(0x33000000),
|
||||||
|
shape = shape,
|
||||||
|
)
|
||||||
|
.then(if (onClick != null && enabled) Modifier.clickable { onClick() } else Modifier)
|
||||||
|
.padding(horizontal = 6.dp, vertical = 6.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(text = count.toString(), color = ink, fontWeight = FontWeight.Bold, fontSize = 20.sp)
|
||||||
|
Text(text = CatanText.good(good), color = ink, fontSize = 11.sp, maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The local player's cards: one tile per resource, and in Cities & Knights one
|
||||||
|
* per commodity as well, in a row that wraps.
|
||||||
|
*/
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun CatanHand(
|
fun CatanHand(
|
||||||
@@ -115,10 +165,15 @@ fun CatanHand(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
) {
|
) {
|
||||||
|
if (state.isCk) {
|
||||||
|
val held = CatanCk.goodsOf(state, seat)
|
||||||
|
Good.entries.forEach { good -> GoodTile(good, held[good] ?: 0) }
|
||||||
|
} else {
|
||||||
Resource.entries.forEach { resource ->
|
Resource.entries.forEach { resource ->
|
||||||
ResourceTile(resource, state.resources[seat][resource] ?: 0)
|
ResourceTile(resource, state.resources[seat][resource] ?: 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -175,6 +230,41 @@ fun CatanPlayerCard(
|
|||||||
CardLine(seats.label(seat), MaterialTheme.typography.labelLarge, FontWeight.Bold)
|
CardLine(seats.label(seat), MaterialTheme.typography.labelLarge, FontWeight.Bold)
|
||||||
}
|
}
|
||||||
CardLine("$points VP", MaterialTheme.typography.titleMedium, FontWeight.Bold)
|
CardLine("$points VP", MaterialTheme.typography.titleMedium, FontWeight.Bold)
|
||||||
|
val ck = state.ck
|
||||||
|
if (ck != null) {
|
||||||
|
val cards = CatanCk.goodsCount(state, seat)
|
||||||
|
val strength = CatanCk.strength(state, seat)
|
||||||
|
if (compact) {
|
||||||
|
CardLine("$cards cards · ⚔$strength", MaterialTheme.typography.bodySmall)
|
||||||
|
} else {
|
||||||
|
CardLine("$cards cards · ${ck.progressCounts[seat]} progress", MaterialTheme.typography.bodySmall)
|
||||||
|
CardLine(
|
||||||
|
"Knights ${CatanCk.knightsOf(state, seat).size} (strength $strength) · " +
|
||||||
|
"Road ${CatanRules.longestRoadFor(state, seat)}",
|
||||||
|
MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Trade, politics, science: the three improvement levels.
|
||||||
|
CardLine(
|
||||||
|
Track.entries.joinToString(" ") { "${it.name.first()}${CatanCk.improvement(state, seat, it)}" },
|
||||||
|
MaterialTheme.typography.bodySmall,
|
||||||
|
color = Color.Unspecified,
|
||||||
|
)
|
||||||
|
val ckAwards = buildList {
|
||||||
|
if (state.longestRoadOwner == seat) add(if (compact) "Road" else "Longest road")
|
||||||
|
ck.metropolises.forEach { (track, at) ->
|
||||||
|
if (at.seat == seat) add(if (compact) track.name.first().toString() else "${CatanText.track(track)} metropolis")
|
||||||
|
}
|
||||||
|
if (ck.merchant?.seat == seat) add("Merchant")
|
||||||
|
}
|
||||||
|
CardLine(
|
||||||
|
text = ckAwards.joinToString(" · ").ifEmpty { BLANK },
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
weight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
return@Column
|
||||||
|
}
|
||||||
if (compact) {
|
if (compact) {
|
||||||
CardLine("${state.resourceCount(seat)} cards", MaterialTheme.typography.bodySmall)
|
CardLine("${state.resourceCount(seat)} cards", MaterialTheme.typography.bodySmall)
|
||||||
} else {
|
} else {
|
||||||
@@ -226,21 +316,36 @@ private fun CardLine(
|
|||||||
// Dice
|
// Dice
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** The two dice as last rolled, with their total. Blank until the first roll. */
|
/**
|
||||||
|
* The two dice as last rolled, with their total. Blank until the first roll. In
|
||||||
|
* Cities & Knights the dice are yellow and red (the red one decides progress
|
||||||
|
* cards) and a third, the event die, shows a ship or a coloured city gate.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CatanDice(dice: List<Int>, modifier: Modifier = Modifier) {
|
fun CatanDice(
|
||||||
|
dice: List<Int>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
ck: Boolean = false,
|
||||||
|
event: EventFace? = null,
|
||||||
|
style: CatanStyle = LocalCatanStyle.current,
|
||||||
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
) {
|
) {
|
||||||
val shown = if (dice.size == 2) dice else listOf(null, null)
|
val shown = if (dice.size == 2) dice else listOf(null, null)
|
||||||
shown.forEach { value ->
|
shown.forEachIndexed { index, value ->
|
||||||
|
val face = when {
|
||||||
|
!ck -> Color.White
|
||||||
|
index == 0 -> Color(0xFFFFF176)
|
||||||
|
else -> Color(0xFFEF9A9A)
|
||||||
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(36.dp)
|
.size(36.dp)
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.background(Color.White)
|
.background(face)
|
||||||
.border(1.dp, Color(0x55000000), RoundedCornerShape(8.dp)),
|
.border(1.dp, Color(0x55000000), RoundedCornerShape(8.dp)),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
@@ -253,6 +358,31 @@ fun CatanDice(dice: List<Int>, modifier: Modifier = Modifier) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (ck) {
|
||||||
|
val gate = when (event) {
|
||||||
|
EventFace.TRADE -> style.track.getValue(Track.TRADE)
|
||||||
|
EventFace.POLITICS -> style.track.getValue(Track.POLITICS)
|
||||||
|
EventFace.SCIENCE -> style.track.getValue(Track.SCIENCE)
|
||||||
|
EventFace.SHIP -> style.barbarian
|
||||||
|
null -> Color(0x33000000)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(width = 52.dp, height = 36.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(gate)
|
||||||
|
.border(1.dp, Color(0x55000000), RoundedCornerShape(8.dp)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (event == EventFace.SHIP) "⛵ Ship" else CatanText.event(event),
|
||||||
|
color = if (event == EventFace.TRADE) Color(0xFF1C1B1F) else Color.White,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
// Always drawn ("= –" before the first roll) so the row never changes width.
|
// Always drawn ("= –" before the first roll) so the row never changes width.
|
||||||
Text(
|
Text(
|
||||||
text = if (dice.size == 2) "= ${dice.sum()}" else "= –",
|
text = if (dice.size == 2) "= ${dice.sum()}" else "= –",
|
||||||
@@ -262,6 +392,48 @@ fun CatanDice(dice: List<Int>, modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cities & Knights: how close the barbarians are, and how the cities on the
|
||||||
|
* island compare with the knights standing ready. **Fixed height**, like every
|
||||||
|
* other strip around the board.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CatanBarbarians(state: CatanState, modifier: Modifier = Modifier, style: CatanStyle = LocalCatanStyle.current) {
|
||||||
|
val ck = state.ck ?: return
|
||||||
|
val cities = state.buildings.count { it?.type == BuildingType.CITY }
|
||||||
|
val defence = (0 until state.playerCount).sumOf { CatanCk.strength(state, it) }
|
||||||
|
Row(
|
||||||
|
modifier = modifier.fillMaxWidth().height(24.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
Text("⛵", fontSize = 14.sp)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.weight(1f)) {
|
||||||
|
// The ship starts seven steps out and lands when the last box fills.
|
||||||
|
val sailed = BARBARIAN_STEPS_DISPLAY - ck.barbarians
|
||||||
|
for (i in 0 until BARBARIAN_STEPS_DISPLAY) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(10.dp)
|
||||||
|
.clip(RoundedCornerShape(3.dp))
|
||||||
|
.background(if (i < sailed) style.barbarian else Color(0x33000000)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val verdict = if (defence >= cities) "holds" else "falls"
|
||||||
|
Text(
|
||||||
|
text = "${CatanText.barbarianSteps(ck.barbarians)} · knights $defence v. $cities cities ($verdict)",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Steps shown on the barbarian strip: the engine's seven. */
|
||||||
|
private const val BARBARIAN_STEPS_DISPLAY = org.prolibertate.games.game.catan.BARBARIAN_STEPS
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Actions
|
// Actions
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -278,6 +450,14 @@ data class CatanActions(
|
|||||||
val canEndFreeRoads: Boolean = false,
|
val canEndFreeRoads: Boolean = false,
|
||||||
/** A setup settlement is chosen and a different one may still be picked. */
|
/** A setup settlement is chosen and a different one may still be picked. */
|
||||||
val canRepickSetup: Boolean = false,
|
val canRepickSetup: Boolean = false,
|
||||||
|
/** Which kinds the Build menu lists: three in the base game, five in Cities & Knights. */
|
||||||
|
val buildKinds: List<BuildKind> = BuildKind.BASE,
|
||||||
|
/** Cities & Knights: the table plays the expansion, so the bar shows its buttons instead. */
|
||||||
|
val ck: Boolean = false,
|
||||||
|
val canImprove: Boolean = false,
|
||||||
|
val canPlayProgress: Boolean = false,
|
||||||
|
val progressCount: Int = 0,
|
||||||
|
val canEndSmith: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -305,6 +485,9 @@ fun CatanActionBar(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
/** Small screens: fold Road / Settlement / City into one "Build" button with a menu. */
|
/** Small screens: fold Road / Settlement / City into one "Build" button with a menu. */
|
||||||
compact: Boolean = false,
|
compact: Boolean = false,
|
||||||
|
onImprove: () -> Unit = {},
|
||||||
|
onProgress: () -> Unit = {},
|
||||||
|
onEndSmith: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Row(
|
Row(
|
||||||
@@ -315,6 +498,7 @@ fun CatanActionBar(
|
|||||||
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.canEndFreeRoads) Button(onClick = onEndFreeRoads) { Text(CatanText.STOP_ROADS) }
|
if (actions.canEndFreeRoads) Button(onClick = onEndFreeRoads) { Text(CatanText.STOP_ROADS) }
|
||||||
|
if (actions.canEndSmith) Button(onClick = onEndSmith) { Text(CatanText.STOP_SMITH) }
|
||||||
if (actions.canEndTurn) Button(onClick = onEndTurn) { Text(CatanText.END_TURN) }
|
if (actions.canEndTurn) Button(onClick = onEndTurn) { Text(CatanText.END_TURN) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,19 +507,28 @@ fun CatanActionBar(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
if (compact) {
|
if (compact || actions.ck) {
|
||||||
BuildMenuButton(actions, onBuild)
|
BuildMenuButton(actions, onBuild)
|
||||||
} else {
|
} else {
|
||||||
BuildButton(BuildKind.ROAD, CatanText.ROAD, CatanText.ROAD_COST, actions, onBuild)
|
BuildButton(BuildKind.ROAD, CatanText.ROAD, CatanText.ROAD_COST, actions, onBuild)
|
||||||
BuildButton(BuildKind.SETTLEMENT, CatanText.SETTLEMENT, CatanText.SETTLEMENT_COST, actions, onBuild)
|
BuildButton(BuildKind.SETTLEMENT, CatanText.SETTLEMENT, CatanText.SETTLEMENT_COST, actions, onBuild)
|
||||||
BuildButton(BuildKind.CITY, CatanText.CITY, CatanText.CITY_COST, actions, onBuild)
|
BuildButton(BuildKind.CITY, CatanText.CITY, CatanText.CITY_COST, actions, onBuild)
|
||||||
}
|
}
|
||||||
|
if (actions.ck) {
|
||||||
|
OutlinedButton(onClick = onImprove, enabled = actions.canImprove) {
|
||||||
|
LabelWithCost(CatanText.IMPROVE, CatanText.NO_COST)
|
||||||
|
}
|
||||||
|
OutlinedButton(onClick = onProgress, enabled = actions.canPlayProgress) {
|
||||||
|
LabelWithCost(CatanText.CARDS_BUTTON, "${actions.progressCount} held")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
OutlinedButton(onClick = onBuyCard, enabled = actions.canBuyCard) {
|
OutlinedButton(onClick = onBuyCard, enabled = actions.canBuyCard) {
|
||||||
LabelWithCost(CatanText.BUY_CARD, CatanText.DEV_COST)
|
LabelWithCost(CatanText.BUY_CARD, CatanText.DEV_COST)
|
||||||
}
|
}
|
||||||
OutlinedButton(onClick = onPlayCard, enabled = actions.canPlayCard) {
|
OutlinedButton(onClick = onPlayCard, enabled = actions.canPlayCard) {
|
||||||
LabelWithCost(CatanText.PLAY_CARD, CatanText.NO_COST)
|
LabelWithCost(CatanText.PLAY_CARD, CatanText.NO_COST)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
OutlinedButton(onClick = onTrade, enabled = actions.canTrade) {
|
OutlinedButton(onClick = onTrade, enabled = actions.canTrade) {
|
||||||
LabelWithCost(CatanText.TRADE, CatanText.NO_COST)
|
LabelWithCost(CatanText.TRADE, CatanText.NO_COST)
|
||||||
}
|
}
|
||||||
@@ -384,7 +577,7 @@ private fun BuildMenuButton(actions: CatanActions, onBuild: (BuildKind?) -> Unit
|
|||||||
OutlinedButton(onClick = { open = true }, enabled = anyEnabled) { label() }
|
OutlinedButton(onClick = { open = true }, enabled = anyEnabled) { label() }
|
||||||
}
|
}
|
||||||
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||||
for (kind in BuildKind.entries) {
|
for (kind in actions.buildKinds) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { LabelWithCost(buildLabel(kind), buildCost(kind)) },
|
text = { LabelWithCost(buildLabel(kind), buildCost(kind)) },
|
||||||
enabled = kind in actions.canBuild,
|
enabled = kind in actions.canBuild,
|
||||||
@@ -402,12 +595,16 @@ private fun buildLabel(kind: BuildKind): String = when (kind) {
|
|||||||
BuildKind.ROAD -> CatanText.ROAD
|
BuildKind.ROAD -> CatanText.ROAD
|
||||||
BuildKind.SETTLEMENT -> CatanText.SETTLEMENT
|
BuildKind.SETTLEMENT -> CatanText.SETTLEMENT
|
||||||
BuildKind.CITY -> CatanText.CITY
|
BuildKind.CITY -> CatanText.CITY
|
||||||
|
BuildKind.WALL -> CatanText.WALL
|
||||||
|
BuildKind.KNIGHT -> CatanText.KNIGHT
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildCost(kind: BuildKind): String = when (kind) {
|
private fun buildCost(kind: BuildKind): String = when (kind) {
|
||||||
BuildKind.ROAD -> CatanText.ROAD_COST
|
BuildKind.ROAD -> CatanText.ROAD_COST
|
||||||
BuildKind.SETTLEMENT -> CatanText.SETTLEMENT_COST
|
BuildKind.SETTLEMENT -> CatanText.SETTLEMENT_COST
|
||||||
BuildKind.CITY -> CatanText.CITY_COST
|
BuildKind.CITY -> CatanText.CITY_COST
|
||||||
|
BuildKind.WALL -> CatanText.WALL_COST
|
||||||
|
BuildKind.KNIGHT -> CatanText.KNIGHT_COST
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -28,17 +28,29 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
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.AqueductChore
|
||||||
import org.prolibertate.games.game.catan.BuyDevCard
|
import org.prolibertate.games.game.catan.BuyDevCard
|
||||||
|
import org.prolibertate.games.game.catan.CatanCk
|
||||||
import org.prolibertate.games.game.catan.CatanMove
|
import org.prolibertate.games.game.catan.CatanMove
|
||||||
import org.prolibertate.games.game.catan.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.DeclineTrade
|
import org.prolibertate.games.game.catan.DeclineTrade
|
||||||
|
import org.prolibertate.games.game.catan.DiscardChore
|
||||||
|
import org.prolibertate.games.game.catan.DiscardGoods
|
||||||
|
import org.prolibertate.games.game.catan.DropProgress
|
||||||
|
import org.prolibertate.games.game.catan.DropProgressChore
|
||||||
import org.prolibertate.games.game.catan.EndFreeRoads
|
import org.prolibertate.games.game.catan.EndFreeRoads
|
||||||
|
import org.prolibertate.games.game.catan.EndSmith
|
||||||
import org.prolibertate.games.game.catan.EndTurn
|
import org.prolibertate.games.game.catan.EndTurn
|
||||||
|
import org.prolibertate.games.game.catan.GiveChore
|
||||||
|
import org.prolibertate.games.game.catan.GiveGoods
|
||||||
|
import org.prolibertate.games.game.catan.MoveKnight
|
||||||
import org.prolibertate.games.game.catan.MoveRobber
|
import org.prolibertate.games.game.catan.MoveRobber
|
||||||
|
import org.prolibertate.games.game.catan.PickDeckChore
|
||||||
import org.prolibertate.games.game.catan.RollDice
|
import org.prolibertate.games.game.catan.RollDice
|
||||||
import org.prolibertate.games.game.catan.SetupPlace
|
import org.prolibertate.games.game.catan.SetupPlace
|
||||||
|
import org.prolibertate.games.game.catan.SpyChore
|
||||||
import org.prolibertate.games.net.MatchController
|
import org.prolibertate.games.net.MatchController
|
||||||
import org.prolibertate.games.ui.ScreenScaffold
|
import org.prolibertate.games.ui.ScreenScaffold
|
||||||
|
|
||||||
@@ -84,6 +96,13 @@ fun CatanScreen(
|
|||||||
var showTrade by remember { mutableStateOf(false) }
|
var showTrade by remember { mutableStateOf(false) }
|
||||||
var confirmingEnd by remember { mutableStateOf(false) }
|
var confirmingEnd by remember { mutableStateOf(false) }
|
||||||
var resultDismissed by remember { mutableStateOf(false) }
|
var resultDismissed by remember { mutableStateOf(false) }
|
||||||
|
// Cities & Knights: the knight whose menu is open, the one about to move,
|
||||||
|
// a progress card waiting for a tap on the board, and the two list dialogs.
|
||||||
|
var knightMenu by remember { mutableStateOf<Int?>(null) }
|
||||||
|
var movingKnight by remember { mutableStateOf<Int?>(null) }
|
||||||
|
var armed by remember { mutableStateOf<ArmedCard?>(null) }
|
||||||
|
var showImprove by remember { mutableStateOf(false) }
|
||||||
|
var showProgress by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val style = remember(dark) { catanStyle(dark) }
|
val style = remember(dark) { catanStyle(dark) }
|
||||||
@@ -97,10 +116,18 @@ fun CatanScreen(
|
|||||||
if (setupVertex != null && legal.none { it is SetupPlace && it.vertex == setupVertex }) {
|
if (setupVertex != null && legal.none { it is SetupPlace && it.vertex == setupVertex }) {
|
||||||
setupVertex = null
|
setupVertex = null
|
||||||
}
|
}
|
||||||
|
if (movingKnight != null && legal.none { it is MoveKnight && it.from == movingKnight }) movingKnight = null
|
||||||
|
if (knightMenu != null && CatanInteraction.knightActions(legal, knightMenu!!).isEmpty()) knightMenu = null
|
||||||
|
armed?.let { card -> if (card.plays.none { it in legal }) armed = null }
|
||||||
if (legal.isEmpty()) {
|
if (legal.isEmpty()) {
|
||||||
showCards = false
|
showCards = false
|
||||||
showTrade = false
|
showTrade = false
|
||||||
victimChoice = null
|
victimChoice = null
|
||||||
|
showImprove = false
|
||||||
|
showProgress = false
|
||||||
|
knightMenu = null
|
||||||
|
movingKnight = null
|
||||||
|
armed = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +138,11 @@ fun CatanScreen(
|
|||||||
victimChoice = null
|
victimChoice = null
|
||||||
showCards = false
|
showCards = false
|
||||||
showTrade = false
|
showTrade = false
|
||||||
|
showImprove = false
|
||||||
|
showProgress = false
|
||||||
|
knightMenu = null
|
||||||
|
movingKnight = null
|
||||||
|
armed = null
|
||||||
}
|
}
|
||||||
|
|
||||||
val endGameAction: @Composable () -> Unit = {
|
val endGameAction: @Composable () -> Unit = {
|
||||||
@@ -127,10 +159,17 @@ fun CatanScreen(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val targets = CatanInteraction.targets(current, legal, building, setupVertex)
|
val targets = CatanInteraction.targets(current, legal, building, setupVertex, movingKnight, armed)
|
||||||
val devHand = CatanInteraction.devHand(current, localSeat, legal)
|
val devHand = CatanInteraction.devHand(current, localSeat, legal)
|
||||||
|
val progressHand = CatanInteraction.progressHand(current, localSeat, legal)
|
||||||
val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty()
|
val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty()
|
||||||
val actions = CatanActions(
|
val actions = CatanActions(
|
||||||
|
buildKinds = if (current.isCk) BuildKind.entries else BuildKind.BASE,
|
||||||
|
ck = current.isCk,
|
||||||
|
canImprove = CatanInteraction.improvable(legal).isNotEmpty(),
|
||||||
|
canPlayProgress = progressHand.any { it.playable },
|
||||||
|
progressCount = progressHand.size,
|
||||||
|
canEndSmith = CatanInteraction.canEndSmith(legal),
|
||||||
canRoll = CatanInteraction.canRoll(legal),
|
canRoll = CatanInteraction.canRoll(legal),
|
||||||
canBuild = CatanInteraction.canBuild(legal),
|
canBuild = CatanInteraction.canBuild(legal),
|
||||||
building = building,
|
building = building,
|
||||||
@@ -142,7 +181,14 @@ fun CatanScreen(
|
|||||||
canRepickSetup = setupVertex != null,
|
canRepickSetup = setupVertex != null,
|
||||||
)
|
)
|
||||||
val rolledHexes = CatanInteraction.rolledHexes(current)
|
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,
|
||||||
|
movingKnight = movingKnight != null,
|
||||||
|
armed = armed?.kind,
|
||||||
|
)
|
||||||
|
|
||||||
val handle: (TapOutcome) -> Unit = { outcome ->
|
val handle: (TapOutcome) -> Unit = { outcome ->
|
||||||
when (outcome) {
|
when (outcome) {
|
||||||
@@ -150,6 +196,8 @@ fun CatanScreen(
|
|||||||
is TapOutcome.Submit -> submit(outcome.move)
|
is TapOutcome.Submit -> submit(outcome.move)
|
||||||
is TapOutcome.PickSetupVertex -> setupVertex = outcome.vertex
|
is TapOutcome.PickSetupVertex -> setupVertex = outcome.vertex
|
||||||
is TapOutcome.PickRobberVictim -> victimChoice = outcome
|
is TapOutcome.PickRobberVictim -> victimChoice = outcome
|
||||||
|
is TapOutcome.PickKnight -> knightMenu = outcome.vertex
|
||||||
|
is TapOutcome.PickFirstHex -> armed = armed?.copy(firstHex = outcome.hex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,12 +218,15 @@ fun CatanScreen(
|
|||||||
state = current,
|
state = current,
|
||||||
targets = targets,
|
targets = targets,
|
||||||
selectedVertex = setupVertex,
|
selectedVertex = setupVertex,
|
||||||
onVertexTap = { handle(CatanInteraction.onVertexTap(it, legal, building, setupVertex)) },
|
onVertexTap = {
|
||||||
onEdgeTap = { handle(CatanInteraction.onEdgeTap(it, legal, building, setupVertex)) },
|
handle(CatanInteraction.onVertexTap(it, legal, building, setupVertex, movingKnight, armed))
|
||||||
onHexTap = { handle(CatanInteraction.onHexTap(it, legal)) },
|
},
|
||||||
|
onEdgeTap = { handle(CatanInteraction.onEdgeTap(it, legal, building, setupVertex, armed)) },
|
||||||
|
onHexTap = { handle(CatanInteraction.onHexTap(it, legal, armed)) },
|
||||||
modifier = boardModifier,
|
modifier = boardModifier,
|
||||||
style = style,
|
style = style,
|
||||||
rolledHexes = rolledHexes,
|
rolledHexes = rolledHexes,
|
||||||
|
selectedKnight = movingKnight ?: knightMenu,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val players: @Composable (Boolean) -> Unit = { grid ->
|
val players: @Composable (Boolean) -> Unit = { grid ->
|
||||||
@@ -200,6 +251,9 @@ fun CatanScreen(
|
|||||||
},
|
},
|
||||||
onRepickSetup = { setupVertex = null },
|
onRepickSetup = { setupVertex = null },
|
||||||
compact = compactBuild,
|
compact = compactBuild,
|
||||||
|
onImprove = { showImprove = true },
|
||||||
|
onProgress = { showProgress = true },
|
||||||
|
onEndSmith = { legal.firstOrNull { m -> m is EndSmith }?.let(submit) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,6 +268,7 @@ fun CatanScreen(
|
|||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
players(true)
|
players(true)
|
||||||
|
if (current.isCk) CatanBarbarians(current, style = style)
|
||||||
promptRow()
|
promptRow()
|
||||||
handAndActions()
|
handAndActions()
|
||||||
CatanLog(current, Modifier.fillMaxWidth().height(120.dp))
|
CatanLog(current, Modifier.fillMaxWidth().height(120.dp))
|
||||||
@@ -222,6 +277,7 @@ fun CatanScreen(
|
|||||||
} else {
|
} else {
|
||||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
players(false)
|
players(false)
|
||||||
|
if (current.isCk) CatanBarbarians(current, style = style)
|
||||||
promptRow()
|
promptRow()
|
||||||
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
board(Modifier)
|
board(Modifier)
|
||||||
@@ -243,6 +299,21 @@ fun CatanScreen(
|
|||||||
showCards = showCards,
|
showCards = showCards,
|
||||||
showTrade = showTrade,
|
showTrade = showTrade,
|
||||||
devHand = devHand,
|
devHand = devHand,
|
||||||
|
knightMenu = knightMenu,
|
||||||
|
showImprove = showImprove,
|
||||||
|
showProgress = showProgress,
|
||||||
|
progressHand = progressHand,
|
||||||
|
onMoveKnight = { from ->
|
||||||
|
knightMenu = null
|
||||||
|
movingKnight = from
|
||||||
|
},
|
||||||
|
onArmCard = { card ->
|
||||||
|
showProgress = false
|
||||||
|
armed = card
|
||||||
|
},
|
||||||
|
onCancelKnight = { knightMenu = null },
|
||||||
|
onCancelImprove = { showImprove = false },
|
||||||
|
onCancelProgress = { showProgress = false },
|
||||||
onExit = onExit,
|
onExit = onExit,
|
||||||
onSubmit = submit,
|
onSubmit = submit,
|
||||||
onStay = controller::dismissAbandoned,
|
onStay = controller::dismissAbandoned,
|
||||||
@@ -315,7 +386,7 @@ private fun PromptRow(prompt: String, notice: String?, state: CatanState) {
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
CatanDice(state.lastDice)
|
CatanDice(state.lastDice, ck = state.isCk, event = state.ck?.eventDie)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,6 +408,15 @@ private fun CatanDialogHost(
|
|||||||
showCards: Boolean,
|
showCards: Boolean,
|
||||||
showTrade: Boolean,
|
showTrade: Boolean,
|
||||||
devHand: List<DevCardInHand>,
|
devHand: List<DevCardInHand>,
|
||||||
|
knightMenu: Int?,
|
||||||
|
showImprove: Boolean,
|
||||||
|
showProgress: Boolean,
|
||||||
|
progressHand: List<ProgressInHand>,
|
||||||
|
onMoveKnight: (Int) -> Unit,
|
||||||
|
onArmCard: (ArmedCard) -> Unit,
|
||||||
|
onCancelKnight: () -> Unit,
|
||||||
|
onCancelImprove: () -> Unit,
|
||||||
|
onCancelProgress: () -> Unit,
|
||||||
onExit: () -> Unit,
|
onExit: () -> Unit,
|
||||||
onSubmit: (CatanMove) -> Unit,
|
onSubmit: (CatanMove) -> Unit,
|
||||||
onStay: () -> Unit,
|
onStay: () -> Unit,
|
||||||
@@ -347,6 +427,7 @@ private fun CatanDialogHost(
|
|||||||
onCancelTrade: () -> Unit,
|
onCancelTrade: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val offer = state.pendingTrade
|
val offer = state.pendingTrade
|
||||||
|
val goodsOffer = state.ck?.goodsOffer
|
||||||
when {
|
when {
|
||||||
abandoned != null -> CatanAbandonedDialog(abandoned, onExit, onStay)
|
abandoned != null -> CatanAbandonedDialog(abandoned, onExit, onStay)
|
||||||
|
|
||||||
@@ -363,6 +444,18 @@ private fun CatanDialogHost(
|
|||||||
onSubmit = onSubmit,
|
onSubmit = onSubmit,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
state.phase == CatanPhase.CHORE && CatanRules.currentSeat(state) == localSeat ->
|
||||||
|
ChoreDialog(state, localSeat, legal, onSubmit)
|
||||||
|
|
||||||
|
state.phase == CatanPhase.TRADE_PENDING && goodsOffer != null && goodsOffer.to == localSeat ->
|
||||||
|
CatanGoodsTradeResponseDialog(
|
||||||
|
offer = goodsOffer,
|
||||||
|
seats = seats,
|
||||||
|
canAccept = CatanInteraction.tradeResponses(legal).first,
|
||||||
|
onAccept = { onSubmit(AcceptTrade) },
|
||||||
|
onDecline = { onSubmit(DeclineTrade) },
|
||||||
|
)
|
||||||
|
|
||||||
state.phase == CatanPhase.TRADE_PENDING && offer != null && offer.to == localSeat ->
|
state.phase == CatanPhase.TRADE_PENDING && offer != null && offer.to == localSeat ->
|
||||||
CatanTradeResponseDialog(
|
CatanTradeResponseDialog(
|
||||||
offer = offer,
|
offer = offer,
|
||||||
@@ -380,8 +473,47 @@ private fun CatanDialogHost(
|
|||||||
onCancel = onCancelVictim,
|
onCancel = onCancelVictim,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
knightMenu != null ->
|
||||||
|
CatanKnightDialog(
|
||||||
|
state = state,
|
||||||
|
vertex = knightMenu,
|
||||||
|
actions = CatanInteraction.knightActions(legal, knightMenu),
|
||||||
|
onDo = onSubmit,
|
||||||
|
onMove = onMoveKnight,
|
||||||
|
onCancel = onCancelKnight,
|
||||||
|
)
|
||||||
|
|
||||||
|
showImprove ->
|
||||||
|
CatanImproveDialog(
|
||||||
|
state = state,
|
||||||
|
seat = localSeat,
|
||||||
|
legal = legal,
|
||||||
|
onBuy = onSubmit,
|
||||||
|
onCancel = onCancelImprove,
|
||||||
|
)
|
||||||
|
|
||||||
|
showProgress ->
|
||||||
|
CatanProgressDialog(
|
||||||
|
hand = progressHand,
|
||||||
|
seats = seats,
|
||||||
|
onPlay = onSubmit,
|
||||||
|
onArm = onArmCard,
|
||||||
|
onCancel = onCancelProgress,
|
||||||
|
)
|
||||||
|
|
||||||
showCards -> CatanPlayCardDialog(devHand, onPlay = onSubmit, onCancel = onCancelCards)
|
showCards -> CatanPlayCardDialog(devHand, onPlay = onSubmit, onCancel = onCancelCards)
|
||||||
|
|
||||||
|
showTrade && state.isCk ->
|
||||||
|
CatanGoodsTradeDialog(
|
||||||
|
state = state,
|
||||||
|
seat = localSeat,
|
||||||
|
seats = seats,
|
||||||
|
legal = legal,
|
||||||
|
onBank = onSubmit,
|
||||||
|
onOffer = onSubmit,
|
||||||
|
onCancel = onCancelTrade,
|
||||||
|
)
|
||||||
|
|
||||||
showTrade ->
|
showTrade ->
|
||||||
CatanTradeDialog(
|
CatanTradeDialog(
|
||||||
state = state,
|
state = state,
|
||||||
@@ -394,3 +526,61 @@ private fun CatanDialogHost(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dialog for the chore at the front of the queue, when it is the local
|
||||||
|
* player's. Chores that are answered by tapping the board (a city to lose, a
|
||||||
|
* metropolis site, a knight to place) have no dialog: the prompt line tells the
|
||||||
|
* player what to tap.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ChoreDialog(
|
||||||
|
state: CatanState,
|
||||||
|
localSeat: Int,
|
||||||
|
legal: List<CatanMove>,
|
||||||
|
onSubmit: (CatanMove) -> Unit,
|
||||||
|
) {
|
||||||
|
val ck = state.ck ?: return
|
||||||
|
val check: (List<org.prolibertate.games.game.catan.Good>, (List<org.prolibertate.games.game.catan.Good>) -> CatanMove) -> Boolean =
|
||||||
|
{ cards, make -> CatanRules.isLegalMove(state, localSeat, make(cards)) }
|
||||||
|
when (val chore = ck.chores.firstOrNull()) {
|
||||||
|
is DiscardChore -> {
|
||||||
|
val quota = minOf(chore.count, CatanCk.goodsCount(state, localSeat))
|
||||||
|
CatanGoodsPickDialog(
|
||||||
|
title = "Discard $quota cards",
|
||||||
|
intro = "Choose $quota cards to give up",
|
||||||
|
held = CatanCk.goodsOf(state, localSeat),
|
||||||
|
quota = quota,
|
||||||
|
confirmLabel = "Discard",
|
||||||
|
selectable = { true },
|
||||||
|
allowed = { check(it, ::DiscardGoods) },
|
||||||
|
onConfirm = { onSubmit(DiscardGoods(it)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is GiveChore -> {
|
||||||
|
val pool = CatanCk.goodsOf(state, localSeat).filterKeys { !chore.commoditiesOnly || it.isCommodity }
|
||||||
|
val quota = minOf(chore.count, pool.values.sum())
|
||||||
|
CatanGoodsPickDialog(
|
||||||
|
title = "Hand over $quota ${if (quota == 1) "card" else "cards"}",
|
||||||
|
intro = if (chore.commoditiesOnly) "Choose a commodity to give" else "Choose which cards to give",
|
||||||
|
held = pool,
|
||||||
|
quota = quota,
|
||||||
|
confirmLabel = "Give",
|
||||||
|
selectable = { !chore.commoditiesOnly || it.isCommodity },
|
||||||
|
allowed = { check(it, ::GiveGoods) },
|
||||||
|
onConfirm = { onSubmit(GiveGoods(it)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is PickDeckChore -> CatanPickDeckDialog(legal) { onSubmit(it) }
|
||||||
|
|
||||||
|
is DropProgressChore -> CatanDropProgressDialog(ck.progressHands[localSeat]) { onSubmit(it) }
|
||||||
|
|
||||||
|
is AqueductChore -> CatanAqueductDialog(legal) { onSubmit(it) }
|
||||||
|
|
||||||
|
is SpyChore -> CatanSpyDialog(ck.progressHands[chore.target], legal) { onSubmit(it) }
|
||||||
|
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package org.prolibertate.games.ui.game.catan
|
|||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import org.prolibertate.games.game.catan.Commodity
|
||||||
import org.prolibertate.games.game.catan.Resource
|
import org.prolibertate.games.game.catan.Resource
|
||||||
import org.prolibertate.games.game.catan.Terrain
|
import org.prolibertate.games.game.catan.Terrain
|
||||||
|
import org.prolibertate.games.game.catan.Track
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every visual decision for the Katan screen, in one place.
|
* Every visual decision for the Katan screen, in one place.
|
||||||
@@ -46,6 +48,19 @@ data class CatanStyle(
|
|||||||
val resource: Map<Resource, Color>,
|
val resource: Map<Resource, Color>,
|
||||||
val resourceInk: Color,
|
val resourceInk: Color,
|
||||||
|
|
||||||
|
// --- Cities & Knights ---------------------------------------------------
|
||||||
|
/** Commodity cards: cloth, coin and paper. */
|
||||||
|
val commodity: Map<Commodity, Color> = LightCommodities,
|
||||||
|
/** Ink on a commodity tile (paper is pale, so it is not white). */
|
||||||
|
val commodityInk: Map<Commodity, Color> = LightCommodityInk,
|
||||||
|
/** The colour of each improvement track, its city gate, its deck and its metropolis. */
|
||||||
|
val track: Map<Track, Color> = LightTracks,
|
||||||
|
/** Wall round a city, and the barbarian strip. */
|
||||||
|
val wall: Color = Color(0xFF5D4037),
|
||||||
|
val barbarian: Color = Color(0xFFB71C1C),
|
||||||
|
/** A knight that is awake and ready to act. */
|
||||||
|
val knightReady: Color = Color(0xFFFFEB3B),
|
||||||
|
|
||||||
// --- Players ------------------------------------------------------------
|
// --- Players ------------------------------------------------------------
|
||||||
/** One colour per seat, in seat order. Roads, settlements, cities, player strip. */
|
/** One colour per seat, in seat order. Roads, settlements, cities, player strip. */
|
||||||
val seats: List<Color>,
|
val seats: List<Color>,
|
||||||
@@ -72,6 +87,25 @@ data class CatanStyle(
|
|||||||
val tapReach: Float = 0.55f,
|
val tapReach: Float = 0.55f,
|
||||||
val tokenText: Float = 0.34f,
|
val tokenText: Float = 0.34f,
|
||||||
val portText: Float = 0.27f,
|
val portText: Float = 0.27f,
|
||||||
|
val knightSize: Float = 0.30f,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val LightCommodities = mapOf(
|
||||||
|
Commodity.CLOTH to Color(0xFF7E57C2),
|
||||||
|
Commodity.COIN to Color(0xFFF9A825),
|
||||||
|
Commodity.PAPER to Color(0xFFECEFF1),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val LightCommodityInk = mapOf(
|
||||||
|
Commodity.CLOTH to Color(0xFFFFFFFF),
|
||||||
|
Commodity.COIN to Color(0xFF3E2723),
|
||||||
|
Commodity.PAPER to Color(0xFF263238),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val LightTracks = mapOf(
|
||||||
|
Track.TRADE to Color(0xFFF2C300),
|
||||||
|
Track.POLITICS to Color(0xFF1E6FD9),
|
||||||
|
Track.SCIENCE to Color(0xFF2E9E4F),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** The light palette, which is also the reference for the dark one. */
|
/** The light palette, which is also the reference for the dark one. */
|
||||||
|
|||||||
@@ -1,12 +1,29 @@
|
|||||||
package org.prolibertate.games.ui.game.catan
|
package org.prolibertate.games.ui.game.catan
|
||||||
|
|
||||||
|
import org.prolibertate.games.game.catan.AqueductChore
|
||||||
|
import org.prolibertate.games.game.catan.CatanCk
|
||||||
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.DevCardType
|
import org.prolibertate.games.game.catan.DevCardType
|
||||||
|
import org.prolibertate.games.game.catan.DiscardChore
|
||||||
|
import org.prolibertate.games.game.catan.DropProgressChore
|
||||||
|
import org.prolibertate.games.game.catan.EventFace
|
||||||
|
import org.prolibertate.games.game.catan.GiveChore
|
||||||
|
import org.prolibertate.games.game.catan.Good
|
||||||
|
import org.prolibertate.games.game.catan.MetropolisChore
|
||||||
|
import org.prolibertate.games.game.catan.PickDeckChore
|
||||||
|
import org.prolibertate.games.game.catan.PillageChore
|
||||||
|
import org.prolibertate.games.game.catan.PlaceKnightChore
|
||||||
import org.prolibertate.games.game.catan.Port
|
import org.prolibertate.games.game.catan.Port
|
||||||
|
import org.prolibertate.games.game.catan.ProgressType
|
||||||
|
import org.prolibertate.games.game.catan.RelocateChore
|
||||||
|
import org.prolibertate.games.game.catan.RemoveKnightChore
|
||||||
import org.prolibertate.games.game.catan.Resource
|
import org.prolibertate.games.game.catan.Resource
|
||||||
|
import org.prolibertate.games.game.catan.SmithChore
|
||||||
|
import org.prolibertate.games.game.catan.SpyChore
|
||||||
import org.prolibertate.games.game.catan.Terrain
|
import org.prolibertate.games.game.catan.Terrain
|
||||||
|
import org.prolibertate.games.game.catan.Track
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Who is who at the table, for wording. [names] may be empty or short — a
|
* Who is who at the table, for wording. [names] may be empty or short — a
|
||||||
@@ -136,7 +153,12 @@ object CatanText {
|
|||||||
seats: CatanSeats,
|
seats: CatanSeats,
|
||||||
setupVertexChosen: Boolean,
|
setupVertexChosen: Boolean,
|
||||||
building: BuildKind?,
|
building: BuildKind?,
|
||||||
|
/** Cities & Knights: a knight has been picked to move, and its destination is awaited. */
|
||||||
|
movingKnight: Boolean = false,
|
||||||
|
/** Cities & Knights: a progress card is waiting for a tap on the board. */
|
||||||
|
armed: ProgressType? = null,
|
||||||
): String {
|
): String {
|
||||||
|
if (state.isCk) return ckPrompt(state, seats, setupVertexChosen, building, movingKnight, armed)
|
||||||
val me = seats.me
|
val me = seats.me
|
||||||
val onClock = CatanRules.currentSeat(state)
|
val onClock = CatanRules.currentSeat(state)
|
||||||
val mine = onClock == me
|
val mine = onClock == me
|
||||||
@@ -218,4 +240,208 @@ object CatanText {
|
|||||||
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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Cities & Knights ----------------------------------------------------
|
||||||
|
|
||||||
|
fun good(good: Good): String = when (good) {
|
||||||
|
Good.LUMBER -> "Lumber"
|
||||||
|
Good.BRICK -> "Brick"
|
||||||
|
Good.WOOL -> "Wool"
|
||||||
|
Good.GRAIN -> "Grain"
|
||||||
|
Good.ORE -> "Ore"
|
||||||
|
Good.CLOTH -> "Cloth"
|
||||||
|
Good.COIN -> "Coin"
|
||||||
|
Good.PAPER -> "Paper"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "2 Lumber + 1 Cloth" for a flat list of goods. */
|
||||||
|
fun goods(cards: List<Good>): String =
|
||||||
|
cards.groupingBy { it }.eachCount().entries
|
||||||
|
.sortedBy { it.key.ordinal }
|
||||||
|
.joinToString(" + ") { (g, n) -> "$n ${good(g)}" }
|
||||||
|
.ifEmpty { "nothing" }
|
||||||
|
|
||||||
|
fun track(track: Track): String = when (track) {
|
||||||
|
Track.TRADE -> "Trade"
|
||||||
|
Track.POLITICS -> "Politics"
|
||||||
|
Track.SCIENCE -> "Science"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the third level of a track gives, for the improvement dialog. */
|
||||||
|
fun trackPower(track: Track): String = when (track) {
|
||||||
|
Track.TRADE -> "From level 3, trade commodities with the bank two for one."
|
||||||
|
Track.POLITICS -> "From level 3, knights can be promoted to mighty."
|
||||||
|
Track.SCIENCE -> "From level 3, when the dice pay you nothing, take any resource."
|
||||||
|
}
|
||||||
|
|
||||||
|
fun knightName(level: Int): String = when (level) {
|
||||||
|
1 -> "Basic knight"
|
||||||
|
2 -> "Strong knight"
|
||||||
|
else -> "Mighty knight"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun event(face: EventFace?): String = when (face) {
|
||||||
|
null -> "–"
|
||||||
|
EventFace.SHIP -> "Ship"
|
||||||
|
EventFace.TRADE -> "Trade"
|
||||||
|
EventFace.POLITICS -> "Politics"
|
||||||
|
EventFace.SCIENCE -> "Science"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun progress(kind: ProgressType): String = CatanCk.progressName(kind)
|
||||||
|
|
||||||
|
fun progressHelp(kind: ProgressType): String = when (kind) {
|
||||||
|
ProgressType.ALCHEMIST -> "Before rolling, choose what the two number dice show."
|
||||||
|
ProgressType.CRANE -> "Your next city improvement costs one commodity less."
|
||||||
|
ProgressType.ENGINEER -> "Raise a wall round one of your cities for free."
|
||||||
|
ProgressType.INVENTOR -> "Swap two number tokens (not 2, 6, 8 or 12)."
|
||||||
|
ProgressType.IRRIGATION -> "Take 2 grain for each fields hex beside your settlements and cities."
|
||||||
|
ProgressType.MEDICINE -> "Upgrade a settlement to a city for 2 ore and 1 grain."
|
||||||
|
ProgressType.MINING -> "Take 2 ore for each mountains hex beside your settlements and cities."
|
||||||
|
ProgressType.PRINTER -> "One victory point."
|
||||||
|
ProgressType.ROAD_BUILDING -> "Build two roads for free."
|
||||||
|
ProgressType.SMITH -> "Promote up to two of your knights one level for free."
|
||||||
|
ProgressType.BISHOP -> "Move the robber and take a card from every player beside its new hex."
|
||||||
|
ProgressType.CONSTITUTION -> "One victory point."
|
||||||
|
ProgressType.DESERTER -> "Another player removes a knight; you place a free one of the same strength."
|
||||||
|
ProgressType.DIPLOMAT -> "Remove an open road. If it was yours, build it again anywhere for free."
|
||||||
|
ProgressType.INTRIGUE -> "Drive an opponent's knight off a crossing beside one of your roads."
|
||||||
|
ProgressType.SABOTEUR -> "Every player with as many points as you or more discards half their cards."
|
||||||
|
ProgressType.SPY -> "Look at another player's progress cards and take one."
|
||||||
|
ProgressType.WARLORD -> "Wake all your knights without paying grain."
|
||||||
|
ProgressType.WEDDING -> "Every player with more points gives you 2 cards."
|
||||||
|
ProgressType.COMMERCIAL_HARBOR -> "Offer a resource to each player; each swaps it for a commodity."
|
||||||
|
ProgressType.MASTER_MERCHANT -> "Take 2 cards from a player with more points."
|
||||||
|
ProgressType.MERCHANT -> "Put the merchant beside your settlement: trade that resource 2:1 and score a point."
|
||||||
|
ProgressType.MERCHANT_FLEET -> "This turn, trade one chosen card type with the bank two for one."
|
||||||
|
ProgressType.RESOURCE_MONOPOLY -> "Name a resource. Every other player gives you 2 of it."
|
||||||
|
ProgressType.TRADE_MONOPOLY -> "Name a commodity. Every other player gives you 1 of it."
|
||||||
|
}
|
||||||
|
|
||||||
|
const val IMPROVE = "Improve"
|
||||||
|
const val KNIGHTS = "Knights"
|
||||||
|
const val CARDS_BUTTON = "Progress"
|
||||||
|
const val WALL = "Wall"
|
||||||
|
const val KNIGHT = "Knight"
|
||||||
|
const val STOP_SMITH = "Done promoting"
|
||||||
|
|
||||||
|
val KNIGHT_COST: String get() = cost(CatanCk.KNIGHT_COST)
|
||||||
|
val ACTIVATE_COST: String get() = cost(CatanCk.ACTIVATE_COST)
|
||||||
|
val WALL_COST: String get() = cost(CatanCk.WALL_COST)
|
||||||
|
|
||||||
|
private fun ckPrompt(
|
||||||
|
state: CatanState,
|
||||||
|
seats: CatanSeats,
|
||||||
|
setupVertexChosen: Boolean,
|
||||||
|
building: BuildKind?,
|
||||||
|
movingKnight: Boolean,
|
||||||
|
armed: ProgressType?,
|
||||||
|
): String {
|
||||||
|
val me = seats.me
|
||||||
|
val onClock = CatanRules.currentSeat(state)
|
||||||
|
val mine = onClock == me
|
||||||
|
val turn = seats.label(state.turn)
|
||||||
|
val ck = state.ck!!
|
||||||
|
|
||||||
|
return when (state.phase) {
|
||||||
|
CatanPhase.GAME_OVER -> {
|
||||||
|
val winner = state.winner
|
||||||
|
if (winner == null) "The game is over." else "${seats.label(winner)} won the game."
|
||||||
|
}
|
||||||
|
|
||||||
|
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND -> when {
|
||||||
|
!mine -> "${seats.label(onClock ?: state.turn)} is placing a settlement and a road…"
|
||||||
|
setupVertexChosen -> "Now tap the road to place beside that settlement."
|
||||||
|
state.phase == CatanPhase.SETUP_SECOND ->
|
||||||
|
"Place your second piece: it is a city, and earns resources from the hexes around it."
|
||||||
|
|
||||||
|
else -> "Place a settlement, then a road."
|
||||||
|
}
|
||||||
|
|
||||||
|
CatanPhase.AWAIT_ROLL -> when {
|
||||||
|
!mine -> "$turn is rolling…"
|
||||||
|
armed != null -> armedPrompt(armed)
|
||||||
|
movingKnight -> "Tap where the knight should go."
|
||||||
|
else -> "Your turn. Roll the dice, or wake knights and play progress cards first."
|
||||||
|
}
|
||||||
|
|
||||||
|
CatanPhase.ROBBER -> when {
|
||||||
|
!mine -> "$turn is moving the robber…"
|
||||||
|
ck.bishop -> "The bishop: tap a hex. Everyone beside it pays you a card."
|
||||||
|
else -> "Move the robber. Tap a hex."
|
||||||
|
}
|
||||||
|
|
||||||
|
CatanPhase.PLAYING -> when {
|
||||||
|
!mine -> "$turn is playing…"
|
||||||
|
armed != null -> armedPrompt(armed)
|
||||||
|
movingKnight -> "Tap where the knight should go."
|
||||||
|
building == BuildKind.ROAD -> "Tap an edge to build a road."
|
||||||
|
building == BuildKind.SETTLEMENT -> "Tap an empty crossing to build a settlement."
|
||||||
|
building == BuildKind.CITY -> "Tap one of your settlements to upgrade it."
|
||||||
|
building == BuildKind.WALL -> "Tap one of your cities to raise a wall."
|
||||||
|
building == BuildKind.KNIGHT -> "Tap an empty crossing at the end of your road to hire a knight."
|
||||||
|
else -> "Your turn. Build, improve, trade, or end the turn. Tap a knight to use it."
|
||||||
|
}
|
||||||
|
|
||||||
|
CatanPhase.FREE_ROADS ->
|
||||||
|
if (mine) {
|
||||||
|
"Place ${state.pendingFreeRoads} free " +
|
||||||
|
if (state.pendingFreeRoads == 1) "road." else "roads."
|
||||||
|
} else {
|
||||||
|
"$turn is placing free roads…"
|
||||||
|
}
|
||||||
|
|
||||||
|
CatanPhase.TRADE_PENDING -> {
|
||||||
|
val offer = ck.goodsOffer
|
||||||
|
when {
|
||||||
|
offer == null -> "Waiting for a reply…"
|
||||||
|
offer.to == me -> "${seats.label(offer.by)} offers you a trade."
|
||||||
|
offer.by == me -> "Waiting for ${seats.label(offer.to)} to answer your offer…"
|
||||||
|
else -> "${seats.label(offer.by)} has offered ${seats.label(offer.to)} a trade."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CatanPhase.DISCARDING -> "Discarding…"
|
||||||
|
|
||||||
|
CatanPhase.CHORE -> {
|
||||||
|
val chore = ck.chores.firstOrNull()
|
||||||
|
val who = seats.label(chore?.seat ?: state.turn)
|
||||||
|
if (chore == null || chore.seat != me) {
|
||||||
|
when (chore) {
|
||||||
|
is PillageChore -> "The barbarians have landed. $who must give up a city."
|
||||||
|
is DiscardChore -> "$who must discard ${chore.count} cards."
|
||||||
|
else -> "Waiting for $who…"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
when (chore) {
|
||||||
|
is DiscardChore -> "Discard ${chore.count} cards."
|
||||||
|
is GiveChore -> "Give ${chore.count} card${if (chore.count == 1) "" else "s"} to ${seats.lower(chore.to)}."
|
||||||
|
is PillageChore -> "The barbarians won. Tap a city of yours to give up."
|
||||||
|
is PickDeckChore -> "Choose a progress deck to draw from."
|
||||||
|
is DropProgressChore -> "You hold too many progress cards. Give one back."
|
||||||
|
is MetropolisChore -> "Tap the city that will bear the ${track(chore.track).lowercase()} metropolis."
|
||||||
|
is AqueductChore -> "Your aqueduct pays. Take any resource."
|
||||||
|
is RelocateChore -> "Your knight was driven off. Tap where it goes."
|
||||||
|
is RemoveKnightChore -> "Tap one of your knights to remove."
|
||||||
|
is PlaceKnightChore -> "Tap where your free ${knightName(chore.level).lowercase()} stands."
|
||||||
|
is SpyChore -> "Choose a progress card to take."
|
||||||
|
is SmithChore -> "Tap a knight to promote (${chore.left} left), or stop."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun armedPrompt(kind: ProgressType): String = when (kind) {
|
||||||
|
ProgressType.ENGINEER -> "Engineer: tap a city to wall."
|
||||||
|
ProgressType.MEDICINE -> "Medicine: tap a settlement to upgrade."
|
||||||
|
ProgressType.MERCHANT -> "Merchant: tap a hex beside one of your buildings."
|
||||||
|
ProgressType.DIPLOMAT -> "Diplomat: tap an open road to remove."
|
||||||
|
ProgressType.INTRIGUE -> "Intrigue: tap an enemy knight beside your road."
|
||||||
|
ProgressType.INVENTOR -> "Inventor: tap two hexes to swap their tokens."
|
||||||
|
else -> "Tap the board."
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "6 steps away", "1 step away" for the barbarian ship. */
|
||||||
|
fun barbarianSteps(steps: Int): String = "$steps ${if (steps == 1) "step" else "steps"} away"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
package org.prolibertate.games.ui.game.catan
|
||||||
|
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.prolibertate.games.game.GameCatalog
|
||||||
|
import org.prolibertate.games.game.catan.AcceptTrade
|
||||||
|
import org.prolibertate.games.game.catan.ActivateKnight
|
||||||
|
import org.prolibertate.games.game.catan.BuildCity
|
||||||
|
import org.prolibertate.games.game.catan.BuildRoad
|
||||||
|
import org.prolibertate.games.game.catan.BuildSettlement
|
||||||
|
import org.prolibertate.games.game.catan.BuildWall
|
||||||
|
import org.prolibertate.games.game.catan.BuyImprovement
|
||||||
|
import org.prolibertate.games.game.catan.CatanAi
|
||||||
|
import org.prolibertate.games.game.catan.CatanBoard
|
||||||
|
import org.prolibertate.games.game.catan.CatanMove
|
||||||
|
import org.prolibertate.games.game.catan.CatanOptions
|
||||||
|
import org.prolibertate.games.game.catan.CatanPhase
|
||||||
|
import org.prolibertate.games.game.catan.CatanRuleSet
|
||||||
|
import org.prolibertate.games.game.catan.CatanRules
|
||||||
|
import org.prolibertate.games.game.catan.CatanState
|
||||||
|
import org.prolibertate.games.game.catan.ChaseRobber
|
||||||
|
import org.prolibertate.games.game.catan.DeclineTrade
|
||||||
|
import org.prolibertate.games.game.catan.DiscardGoods
|
||||||
|
import org.prolibertate.games.game.catan.DropProgress
|
||||||
|
import org.prolibertate.games.game.catan.EndFreeRoads
|
||||||
|
import org.prolibertate.games.game.catan.EndSmith
|
||||||
|
import org.prolibertate.games.game.catan.EndTurn
|
||||||
|
import org.prolibertate.games.game.catan.GiveGoods
|
||||||
|
import org.prolibertate.games.game.catan.GoodsBankTrade
|
||||||
|
import org.prolibertate.games.game.catan.HireKnight
|
||||||
|
import org.prolibertate.games.game.catan.MoveKnight
|
||||||
|
import org.prolibertate.games.game.catan.MoveRobber
|
||||||
|
import org.prolibertate.games.game.catan.OfferGoods
|
||||||
|
import org.prolibertate.games.game.catan.PickDeck
|
||||||
|
import org.prolibertate.games.game.catan.PillageCity
|
||||||
|
import org.prolibertate.games.game.catan.PlaceKnight
|
||||||
|
import org.prolibertate.games.game.catan.PlaceMetropolis
|
||||||
|
import org.prolibertate.games.game.catan.PlayProgress
|
||||||
|
import org.prolibertate.games.game.catan.PromoteKnight
|
||||||
|
import org.prolibertate.games.game.catan.RelocateKnight
|
||||||
|
import org.prolibertate.games.game.catan.RemoveKnight
|
||||||
|
import org.prolibertate.games.game.catan.RollDice
|
||||||
|
import org.prolibertate.games.game.catan.SetupPlace
|
||||||
|
import org.prolibertate.games.game.catan.SpyTake
|
||||||
|
import org.prolibertate.games.game.catan.TakeAqueduct
|
||||||
|
import org.prolibertate.games.game.engine.PlayerKind
|
||||||
|
import org.prolibertate.games.game.engine.PlayerSlot
|
||||||
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cities & Knights has no device in this test suite, so the screen's logic is
|
||||||
|
* checked another way: engines play whole games, and at every position the
|
||||||
|
* player on the clock is treated as the local one. For every move the engine says
|
||||||
|
* is legal, [CatanInteraction] (and the dialogs' own filters) must offer a way to
|
||||||
|
* make it — a tap, a button or a dialog choice. A legal move that no control can
|
||||||
|
* produce would be a stuck game on a real phone.
|
||||||
|
*/
|
||||||
|
class CatanCkInteractionTest {
|
||||||
|
|
||||||
|
private val json = Json { encodeDefaults = true }
|
||||||
|
|
||||||
|
private fun config(players: Int, seed: Long) = TableConfig(
|
||||||
|
gameId = GameCatalog.CATAN,
|
||||||
|
seats = (0 until players).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
|
||||||
|
optionsJson = json.encodeToString(
|
||||||
|
CatanOptions(players, pointsToWin = 13, ruleSet = CatanRuleSet.CITIES_AND_KNIGHTS),
|
||||||
|
),
|
||||||
|
seed = seed,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** How a move reaches the engine from the screen, or null if nothing can produce it. */
|
||||||
|
private fun reachable(state: CatanState, legal: List<CatanMove>, move: CatanMove): String? {
|
||||||
|
fun tapVertex(vertex: Int, building: BuildKind? = null, moving: Int? = null, armed: ArmedCard? = null): TapOutcome =
|
||||||
|
CatanInteraction.onVertexTap(vertex, legal, building, null, moving, armed)
|
||||||
|
|
||||||
|
return when (move) {
|
||||||
|
is SetupPlace -> {
|
||||||
|
val vertex = CatanInteraction.targets(state, legal, null, null).vertices
|
||||||
|
val edges = CatanInteraction.targets(state, legal, null, move.vertex).edges
|
||||||
|
"setup".takeIf {
|
||||||
|
move.vertex in vertex && move.edge in edges &&
|
||||||
|
CatanInteraction.onVertexTap(move.vertex, legal, null, null) is TapOutcome.PickSetupVertex &&
|
||||||
|
CatanInteraction.onEdgeTap(move.edge, legal, null, move.vertex) == TapOutcome.Submit(move)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is BuildRoad -> {
|
||||||
|
val targets = CatanInteraction.targets(state, legal, BuildKind.ROAD, null)
|
||||||
|
"road".takeIf {
|
||||||
|
move.edge in targets.edges &&
|
||||||
|
CatanInteraction.onEdgeTap(move.edge, legal, BuildKind.ROAD, null) == TapOutcome.Submit(move)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is BuildSettlement -> viaBuild(state, legal, BuildKind.SETTLEMENT, move.vertex, move)
|
||||||
|
is BuildCity -> viaBuild(state, legal, BuildKind.CITY, move.vertex, move)
|
||||||
|
is BuildWall -> viaBuild(state, legal, BuildKind.WALL, move.vertex, move)
|
||||||
|
is HireKnight -> viaBuild(state, legal, BuildKind.KNIGHT, move.vertex, move)
|
||||||
|
|
||||||
|
is MoveRobber -> {
|
||||||
|
val hex = CatanBoard.hexIndex.getValue(move.hex)
|
||||||
|
val targets = CatanInteraction.targets(state, legal, null, null)
|
||||||
|
val tap = CatanInteraction.onHexTap(hex, legal)
|
||||||
|
"robber".takeIf {
|
||||||
|
hex in targets.hexes &&
|
||||||
|
(tap == TapOutcome.Submit(move) || (tap is TapOutcome.PickRobberVictim && move.victim in tap.victims))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is MoveKnight -> {
|
||||||
|
val targets = CatanInteraction.targets(state, legal, null, null, movingKnight = move.from)
|
||||||
|
"moveKnight".takeIf {
|
||||||
|
move.to in targets.vertices && tapVertex(move.to, moving = move.from) == TapOutcome.Submit(move) &&
|
||||||
|
move.from in CatanInteraction.targets(state, legal, null, null).knights &&
|
||||||
|
CatanInteraction.knightActions(legal, move.from).any { it is MoveKnight }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is ActivateKnight, is ChaseRobber, is PromoteKnight -> {
|
||||||
|
val vertex = when (move) {
|
||||||
|
is ActivateKnight -> move.vertex
|
||||||
|
is ChaseRobber -> move.vertex
|
||||||
|
else -> (move as PromoteKnight).vertex
|
||||||
|
}
|
||||||
|
val smith = legal.any { it is EndSmith }
|
||||||
|
if (move is PromoteKnight && smith) {
|
||||||
|
val targets = CatanInteraction.targets(state, legal, null, null)
|
||||||
|
"smith".takeIf {
|
||||||
|
vertex in targets.vertices && tapVertex(vertex) == TapOutcome.Submit(move)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val targets = CatanInteraction.targets(state, legal, null, null)
|
||||||
|
"knightMenu".takeIf {
|
||||||
|
vertex in targets.knights && tapVertex(vertex) == TapOutcome.PickKnight(vertex) &&
|
||||||
|
move in CatanInteraction.knightActions(legal, vertex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is PillageCity -> viaChore(state, legal, move.vertex, move)
|
||||||
|
is PlaceMetropolis -> viaChore(state, legal, move.vertex, move)
|
||||||
|
is RelocateKnight -> viaChore(state, legal, move.to, move)
|
||||||
|
is RemoveKnight -> viaChore(state, legal, move.vertex, move)
|
||||||
|
is PlaceKnight -> viaChore(state, legal, move.vertex, move)
|
||||||
|
|
||||||
|
is PlayProgress -> viaCard(state, legal, move)
|
||||||
|
|
||||||
|
is BuyImprovement -> "improve".takeIf { move.track in CatanInteraction.improvable(legal) }
|
||||||
|
|
||||||
|
is GoodsBankTrade ->
|
||||||
|
"bankTrade".takeIf { move.get in CatanInteraction.goodsBankTrades(legal)[move.give].orEmpty() }
|
||||||
|
|
||||||
|
RollDice(), is RollDice -> "roll".takeIf { CatanInteraction.canRoll(legal) }
|
||||||
|
EndTurn -> "endTurn".takeIf { CatanInteraction.canEndTurn(legal) }
|
||||||
|
EndFreeRoads -> "endRoads".takeIf { CatanInteraction.canEndFreeRoads(legal) }
|
||||||
|
EndSmith -> "endSmith".takeIf { CatanInteraction.canEndSmith(legal) }
|
||||||
|
AcceptTrade, DeclineTrade -> "tradeReply".takeIf { CatanInteraction.tradeResponses(legal) != (false to false) }
|
||||||
|
|
||||||
|
// Answered in a dialog that offers exactly the moves in the legal list.
|
||||||
|
is PickDeck, is DropProgress, is TakeAqueduct, is SpyTake -> "choreDialog"
|
||||||
|
|
||||||
|
// Structural: the discard and give dialogs build these and ask the engine.
|
||||||
|
is DiscardGoods, is GiveGoods -> "goodsDialog"
|
||||||
|
is OfferGoods -> "tradeDialog"
|
||||||
|
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun viaBuild(state: CatanState, legal: List<CatanMove>, kind: BuildKind, vertex: Int, move: CatanMove): String? {
|
||||||
|
val targets = CatanInteraction.targets(state, legal, kind, null)
|
||||||
|
return "build $kind".takeIf {
|
||||||
|
vertex in targets.vertices &&
|
||||||
|
CatanInteraction.onVertexTap(vertex, legal, kind, null) == TapOutcome.Submit(move) &&
|
||||||
|
kind in CatanInteraction.canBuild(legal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun viaChore(state: CatanState, legal: List<CatanMove>, vertex: Int, move: CatanMove): String? {
|
||||||
|
val targets = CatanInteraction.targets(state, legal, null, null)
|
||||||
|
return "chore".takeIf {
|
||||||
|
vertex in targets.vertices &&
|
||||||
|
CatanInteraction.onVertexTap(vertex, legal, null, null) == TapOutcome.Submit(move)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun viaCard(state: CatanState, legal: List<CatanMove>, move: PlayProgress): String? {
|
||||||
|
val seat = CatanRules.currentSeat(state) ?: return null
|
||||||
|
val entry = CatanInteraction.progressHand(state, seat, legal).firstOrNull { it.card.id == move.card }
|
||||||
|
?: return null
|
||||||
|
val kind = entry.card.kind
|
||||||
|
if (move !in entry.plays) return null
|
||||||
|
if (kind !in CatanInteraction.BOARD_CARDS) return "cardDialog"
|
||||||
|
|
||||||
|
val armed = ArmedCard(kind, entry.plays)
|
||||||
|
val targets = CatanInteraction.targets(state, legal, null, null, armed = armed)
|
||||||
|
return when (kind) {
|
||||||
|
org.prolibertate.games.game.catan.ProgressType.DIPLOMAT ->
|
||||||
|
"armed".takeIf {
|
||||||
|
move.edge in targets.edges &&
|
||||||
|
CatanInteraction.onEdgeTap(move.edge!!, legal, null, null, armed) == TapOutcome.Submit(move)
|
||||||
|
}
|
||||||
|
|
||||||
|
org.prolibertate.games.game.catan.ProgressType.MERCHANT ->
|
||||||
|
"armed".takeIf {
|
||||||
|
move.hexA in targets.hexes &&
|
||||||
|
CatanInteraction.onHexTap(move.hexA!!, legal, armed) == TapOutcome.Submit(move)
|
||||||
|
}
|
||||||
|
|
||||||
|
org.prolibertate.games.game.catan.ProgressType.INVENTOR -> {
|
||||||
|
val first = CatanInteraction.onHexTap(move.hexA!!, legal, armed)
|
||||||
|
if (first != TapOutcome.PickFirstHex(move.hexA!!)) return null
|
||||||
|
val second = armed.copy(firstHex = move.hexA)
|
||||||
|
val partners = CatanInteraction.targets(state, legal, null, null, armed = second).hexes
|
||||||
|
"armed".takeIf {
|
||||||
|
move.hexB in partners &&
|
||||||
|
CatanInteraction.onHexTap(move.hexB!!, legal, second) == TapOutcome.Submit(move)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else ->
|
||||||
|
"armed".takeIf {
|
||||||
|
move.vertex in targets.vertices &&
|
||||||
|
CatanInteraction.onVertexTap(move.vertex!!, legal, null, null, null, armed) == TapOutcome.Submit(move)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun playAndCheck(players: Int, seed: Long): Map<String, Int> {
|
||||||
|
val ai = CatanAi()
|
||||||
|
var state = CatanRules.initialState(config(players, seed))
|
||||||
|
val seen = mutableMapOf<String, Int>()
|
||||||
|
var moves = 0
|
||||||
|
while (!CatanRules.isFinished(state) && moves++ < 20_000) {
|
||||||
|
val seat = CatanRules.currentSeat(state) ?: break
|
||||||
|
val legal = CatanRules.legalMoves(state, seat)
|
||||||
|
for (move in legal) {
|
||||||
|
val how = reachable(state, legal, move)
|
||||||
|
assertTrue(
|
||||||
|
"seed $seed: no control produces $move in ${state.phase} " +
|
||||||
|
"(chore ${state.ck?.chores?.firstOrNull()}); log tail: ${state.log.takeLast(3)}",
|
||||||
|
how != null,
|
||||||
|
)
|
||||||
|
seen.merge(how!!, 1, Int::plus)
|
||||||
|
}
|
||||||
|
state = CatanRules.applyMove(state, seat, ai.chooseMove(state, seat, legal))
|
||||||
|
}
|
||||||
|
return seen
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every legal move in a full game has a control that produces it`() {
|
||||||
|
val total = mutableMapOf<String, Int>()
|
||||||
|
listOf(3 to 21L, 4 to 22L, 3 to 23L, 4 to 24L).forEach { (players, seed) ->
|
||||||
|
playAndCheck(players, seed).forEach { (k, v) -> total.merge(k, v, Int::plus) }
|
||||||
|
}
|
||||||
|
println("CK UI coverage: ${total.toSortedMap()}")
|
||||||
|
// The ways the screen builds, moves and answers all got exercised.
|
||||||
|
listOf("setup", "road", "build SETTLEMENT", "build CITY", "knightMenu", "improve", "bankTrade", "roll", "endTurn")
|
||||||
|
.forEach { assertTrue("never exercised: $it in $total", (total[it] ?: 0) > 0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package org.prolibertate.games.ui.game.catan
|
||||||
|
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.prolibertate.games.game.GameCatalog
|
||||||
|
import org.prolibertate.games.game.catan.CatanAi
|
||||||
|
import org.prolibertate.games.game.catan.CatanOptions
|
||||||
|
import org.prolibertate.games.game.catan.CatanRuleSet
|
||||||
|
import org.prolibertate.games.game.catan.CatanRules
|
||||||
|
import org.prolibertate.games.game.catan.ProgressType
|
||||||
|
import org.prolibertate.games.game.engine.PlayerKind
|
||||||
|
import org.prolibertate.games.game.engine.PlayerSlot
|
||||||
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
|
import org.prolibertate.games.ui.catanDefaultPoints
|
||||||
|
import org.prolibertate.games.ui.catanPointsChoices
|
||||||
|
import org.prolibertate.games.ui.catanSwitchRules
|
||||||
|
|
||||||
|
class CatanCkTextTest {
|
||||||
|
|
||||||
|
private val json = Json { encodeDefaults = true }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every position of a Cities and Knights game has a prompt for every seat`() {
|
||||||
|
val config = TableConfig(
|
||||||
|
gameId = GameCatalog.CATAN,
|
||||||
|
seats = (0 until 3).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
|
||||||
|
optionsJson = json.encodeToString(
|
||||||
|
CatanOptions(3, 13, CatanRuleSet.CITIES_AND_KNIGHTS),
|
||||||
|
),
|
||||||
|
seed = 31L,
|
||||||
|
)
|
||||||
|
var state = CatanRules.initialState(config)
|
||||||
|
val ai = CatanAi()
|
||||||
|
var moves = 0
|
||||||
|
while (!CatanRules.isFinished(state) && moves++ < 20_000) {
|
||||||
|
for (me in 0 until 3) {
|
||||||
|
val seats = CatanSeats(me, listOf("A", "B", "C"))
|
||||||
|
val text = CatanText.prompt(state, seats, setupVertexChosen = false, building = null)
|
||||||
|
assertTrue("blank prompt in ${state.phase} for seat $me", text.isNotBlank())
|
||||||
|
}
|
||||||
|
val seat = CatanRules.currentSeat(state) ?: break
|
||||||
|
state = CatanRules.applyMove(state, seat, ai.chooseMove(state, seat, CatanRules.legalMoves(state, seat)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every progress card has a name and a description`() {
|
||||||
|
ProgressType.entries.forEach {
|
||||||
|
assertTrue(CatanText.progress(it).isNotBlank())
|
||||||
|
assertTrue("no help for $it", CatanText.progressHelp(it).length > 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the setup screen offers each rule set its own targets and switches sensibly`() {
|
||||||
|
assertEquals(listOf(5, 8, 10, 12), catanPointsChoices(CatanRuleSet.BASE))
|
||||||
|
assertEquals(listOf(8, 10, 13, 15), catanPointsChoices(CatanRuleSet.CITIES_AND_KNIGHTS))
|
||||||
|
assertEquals(10, catanDefaultPoints(CatanRuleSet.BASE))
|
||||||
|
assertEquals(13, catanDefaultPoints(CatanRuleSet.CITIES_AND_KNIGHTS))
|
||||||
|
|
||||||
|
val base = CatanOptions()
|
||||||
|
val ck = catanSwitchRules(base, CatanRuleSet.CITIES_AND_KNIGHTS)
|
||||||
|
assertEquals(CatanRuleSet.CITIES_AND_KNIGHTS, ck.ruleSet)
|
||||||
|
assertEquals("the published target for the new rules", 13, ck.pointsToWin)
|
||||||
|
assertEquals(10, catanSwitchRules(ck, CatanRuleSet.BASE).pointsToWin)
|
||||||
|
|
||||||
|
// A short game picked on purpose is kept when both menus have it.
|
||||||
|
val short = catanSwitchRules(base.copy(pointsToWin = 8), CatanRuleSet.CITIES_AND_KNIGHTS)
|
||||||
|
assertEquals(8, short.pointsToWin)
|
||||||
|
assertEquals(3, ck.playerCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user