From 0e387ad6d4266d2649b679be3fbbe9b9ed35ca3c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 01:26:13 +0000 Subject: [PATCH 1/7] Stop the Euchre Pass button being pushed off screen Passing was always legal in the first round of bidding, but the buttons were laid out in a horizontally scrolling row with Pass appended last. On a phone "Pick it up" and "Pick it up, alone" filled the width, so Pass sat off the right edge and bidding looked compulsory. The second round was worse, with up to six suit buttons ahead of it. The bid options now wrap instead of scrolling, and Pass is lifted out of that group into its own full-width button underneath, where it cannot be clipped or scrolled away. Stick the dealer is the one case where there genuinely is no Pass, so it now explains itself rather than looking like the same missing button. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN --- .../games/ui/game/EuchreScreen.kt | 43 +++++++++++++++---- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/org/prolibertate/games/ui/game/EuchreScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/EuchreScreen.kt index fc4d513..ed64229 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/EuchreScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/EuchreScreen.kt @@ -5,6 +5,8 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -242,6 +244,7 @@ private fun TrickArea(state: EuchreState, localSeat: Int, cardWidth: Dp) { } } +@OptIn(ExperimentalLayoutApi::class) @Composable private fun BiddingControls( state: EuchreState, @@ -260,6 +263,8 @@ private fun BiddingControls( // the turn card to somebody else or to yourself. val youAreDealer = state.turn == state.dealer val upCard = state.upCard + val canPass = Pass in legal + val choices = legal.filter { it != Pass } Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Text( @@ -282,13 +287,25 @@ private fun BiddingControls( style = MaterialTheme.typography.bodySmall, ) } - Row( - modifier = Modifier.horizontalScroll(rememberScrollState()), + // Stick the dealer is the one case where there is genuinely no way out, + // so say so rather than leaving it looking like a missing button. + if (!canPass) { + Text( + text = "Everyone has passed, so as dealer you must name a suit — " + + "that's stick the dealer. Turn it off in game setup if you'd " + + "rather the hand be thrown in.", + style = MaterialTheme.typography.bodySmall, + ) + } + + // Wraps rather than scrolls. A scrolling row pushed Pass off the right + // edge of a phone screen, which made bidding look compulsory. + FlowRow( horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), ) { - legal.forEach { move -> + choices.forEach { move -> val label = when (move) { - is Pass -> "Pass" is OrderUp -> when { youAreDealer && move.alone -> "Pick it up, alone" youAreDealer -> "Pick it up" @@ -301,12 +318,20 @@ private fun BiddingControls( is Discard -> "Discard ${move.card.label}" is PlayCard -> move.card.label + is Pass -> "Pass" } - if (move is Pass) { - OutlinedButton(onClick = { onMove(move) }) { Text(label) } - } else { - Button(onClick = { onMove(move) }) { Text(label) } - } + Button(onClick = { onMove(move) }) { Text(label) } + } + } + + // Kept out of the wrapping group and full width so it can never be + // clipped, scrolled away or mistaken for one of the bids. + if (canPass) { + OutlinedButton( + onClick = { onMove(Pass) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Pass") } } } From 76c136718d2761a761d8141f008f46ef9e377bba Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 01:30:36 +0000 Subject: [PATCH 2/7] Let the player name field be cleared Clearing the name put "Player" straight back before a real one could be typed. Three things combined to cause it: - setPlayerName coerced a blank value to "Player" on write, so deleting the last character immediately persisted the default. - The settings flow then emitted that default, and the text field's remember was keyed on the stored name, so it re-seeded itself from the value that had just been written. - The default also lived in the stored value, so a fresh install had "Player" pre-filled and had to be cleared to begin with. The stored name is now whatever the user typed, blank included. The default moved to Settings.displayName, which is what the lobby and seat labels use, so it applies where a name is shown rather than to what is being edited. The field keeps its own edit state once touched, and shows "Player" as placeholder text with a note explaining the fallback. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN --- .../games/settings/SettingsRepository.kt | 21 ++++++++++++++++--- .../java/org/prolibertate/games/ui/AppRoot.kt | 4 ++-- .../prolibertate/games/ui/SettingsScreen.kt | 18 +++++++++++++--- 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/org/prolibertate/games/settings/SettingsRepository.kt b/app/src/main/java/org/prolibertate/games/settings/SettingsRepository.kt index 64b9375..c086124 100644 --- a/app/src/main/java/org/prolibertate/games/settings/SettingsRepository.kt +++ b/app/src/main/java/org/prolibertate/games/settings/SettingsRepository.kt @@ -25,10 +25,23 @@ data class Settings( val soundEnabled: Boolean = true, val animationSpeed: Float = 1.0f, val checkForUpdatesOnLaunch: Boolean = true, - val playerName: String = "Player", + /** + * Exactly what the user has typed, which may be blank while they are + * mid-edit. Nothing substitutes a default into this value: doing so meant + * that clearing the field wrote "Player" straight back and the text + * snapped back before a real name could be typed. + */ + val playerName: String = "", /** Which builds the updater offers. Production unless deliberately changed. */ val updateChannel: UpdateChannel = UpdateChannel.PRODUCTION, ) { + /** + * The name other players actually see. This is where the default lives, so + * an empty field is a display concern rather than something written back + * into what the user is typing. + */ + val displayName: String get() = playerName.trim().ifBlank { DEFAULT_PLAYER_NAME } + /** Scales a nominal duration by the chosen speed. */ fun scaled(millis: Long): Long = (millis / animationSpeed.coerceIn(MIN_SPEED, MAX_SPEED)).toLong() @@ -36,6 +49,7 @@ data class Settings( companion object { const val MIN_SPEED = 0.5f const val MAX_SPEED = 2.0f + const val DEFAULT_PLAYER_NAME = "Player" } } @@ -54,7 +68,7 @@ class SettingsRepository(private val context: Context) { soundEnabled = prefs[Keys.SOUND] ?: true, animationSpeed = prefs[Keys.SPEED] ?: 1.0f, checkForUpdatesOnLaunch = prefs[Keys.UPDATE_ON_LAUNCH] ?: true, - playerName = prefs[Keys.NAME] ?: "Player", + playerName = prefs[Keys.NAME] ?: "", updateChannel = prefs[Keys.CHANNEL] ?.let { stored -> runCatching { UpdateChannel.valueOf(stored) }.getOrNull() } ?: UpdateChannel.PRODUCTION, @@ -79,7 +93,8 @@ class SettingsRepository(private val context: Context) { context.dataStore.edit { it[Keys.UPDATE_ON_LAUNCH] = enabled } } + /** Stores the name verbatim. The fallback belongs to [Settings.displayName]. */ suspend fun setPlayerName(name: String) { - context.dataStore.edit { it[Keys.NAME] = name.trim().ifBlank { "Player" } } + context.dataStore.edit { it[Keys.NAME] = name } } } diff --git a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt index dc17eff..0d596c4 100644 --- a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt +++ b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt @@ -107,7 +107,7 @@ fun AppRoot(env: AppEnv) { is Route.Setup -> GameSetupScreen( descriptor = GameCatalog.byId(route.gameId)!!, - playerName = settings.playerName, + playerName = settings.displayName, onBack = { pop() }, onPlayOffline = { config -> push(Route.Play(route.gameId, config, true, 0)) }, onHostOnline = { optionsJson -> @@ -118,7 +118,7 @@ fun AppRoot(env: AppEnv) { is Route.Lobby -> LobbyScreen( env = env, route = route, - playerName = settings.playerName, + playerName = settings.displayName, onBack = { pop() }, onStart = { config, hosting, seat -> push(Route.Play(config.gameId, config, hosting, seat)) diff --git a/app/src/main/java/org/prolibertate/games/ui/SettingsScreen.kt b/app/src/main/java/org/prolibertate/games/ui/SettingsScreen.kt index a85bf6d..5e072a7 100644 --- a/app/src/main/java/org/prolibertate/games/ui/SettingsScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/SettingsScreen.kt @@ -36,7 +36,11 @@ fun SettingsScreen( onBack: () -> Unit, ) { val scope = rememberCoroutineScope() - var name by remember(settings.playerName) { mutableStateOf(settings.playerName) } + // Null until the user touches the field, at which point their text takes + // over for good. Keying this off the stored name instead meant every + // keystroke re-seeded the field from what had just been written, so the + // caret jumped and a cleared field refilled itself. + var editedName by remember { mutableStateOf(null) } var updateStatus by remember { mutableStateOf(null) } var pendingRelease by remember { mutableStateOf(null) } @@ -86,14 +90,22 @@ fun SettingsScreen( style = MaterialTheme.typography.bodySmall, ) OutlinedTextField( - value = name, + value = editedName ?: settings.playerName, onValueChange = { entered -> - name = entered + editedName = entered scope.launch { env.settingsRepository.setPlayerName(entered) } }, + placeholder = { Text(Settings.DEFAULT_PLAYER_NAME) }, singleLine = true, modifier = Modifier.fillMaxWidth().padding(top = 8.dp), ) + if ((editedName ?: settings.playerName).isBlank()) { + Text( + text = "Leave it empty and you'll show up as " + + "${Settings.DEFAULT_PLAYER_NAME}.", + style = MaterialTheme.typography.bodySmall, + ) + } } Divider() From 39a312b157d2f898431efa6e888610745cfd22ba Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 01:40:43 +0000 Subject: [PATCH 3/7] Stop the AI ordering up every deal; hold and sweep finished tricks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The real cause of being forced to pick up the turn card was the AI, not the Pass button. Measured over 2000 deals, the computer ordered up on 100% of them and the dealer was dragged into picking up on 97.2% — bidding never once reached the second round, so a human dealer never got a say. Average hand scored about 11 against a bid threshold of 9, so essentially every hand bid. - Voids no longer count towards a hand unless there is trump left to ruff with, which was inflating precisely the weak hands that should pass. - Thresholds recalibrated against the actual score distribution. - EuchreBiddingRateTest pins the result so this cannot drift back: now 32.8% ordered up in round one, dealer forced to pick up on 12.8%, and round two reached on 67%. Finished tricks are now held and swept, for this and any later trick-taking game: - The engine kept a trick only until it resolved, so the fourth card was never visible — it vanished the instant it landed. Completed tricks are now retained in state until the next card is played. - The table holds them for a second, names the winner, then slides all four towards that player's edge and fades them out. The hold scales with the animation speed setting. - MatchController gains a general holdBeforeNextMove hook so the AI waits out the animation rather than playing over it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN --- .../games/game/euchre/EuchreAi.kt | 30 +++-- .../games/game/euchre/EuchreModel.kt | 6 + .../games/game/euchre/EuchreRules.kt | 7 +- .../prolibertate/games/net/MatchController.kt | 8 +- .../org/prolibertate/games/ui/PlayScreen.kt | 17 ++- .../games/ui/game/EuchreScreen.kt | 82 +++++++++++- .../game/euchre/EuchreBiddingRateTest.kt | 121 ++++++++++++++++++ .../games/game/euchre/EuchreRulesTest.kt | 39 ++++++ 8 files changed, 293 insertions(+), 17 deletions(-) create mode 100644 app/src/test/java/org/prolibertate/games/game/euchre/EuchreBiddingRateTest.kt diff --git a/app/src/main/java/org/prolibertate/games/game/euchre/EuchreAi.kt b/app/src/main/java/org/prolibertate/games/game/euchre/EuchreAi.kt index ea8f6ee..52cd6da 100644 --- a/app/src/main/java/org/prolibertate/games/game/euchre/EuchreAi.kt +++ b/app/src/main/java/org/prolibertate/games/game/euchre/EuchreAi.kt @@ -16,14 +16,20 @@ import org.prolibertate.games.game.engine.GameAi class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) : GameAi { + /** + * Thresholds are calibrated against the score an average five-card hand + * earns from [handValue], which sits around 11. A threshold near that mean + * means every seat bids and the card is never turned down — see + * EuchreBiddingRateTest, which pins the resulting order-up rate. + */ enum class Difficulty(val bidThreshold: Int, val aloneThreshold: Int) { /** Bids loosely and plays straightforwardly. */ - EASY(bidThreshold = 7, aloneThreshold = 26), + EASY(bidThreshold = 21, aloneThreshold = 32), - NORMAL(bidThreshold = 9, aloneThreshold = 24), + NORMAL(bidThreshold = 24, aloneThreshold = 32), /** Bids tighter and is harder to sneak a trick past. */ - CAUTIOUS(bidThreshold = 11, aloneThreshold = 22), + CAUTIOUS(bidThreshold = 27, aloneThreshold = 31), } override fun chooseMove(state: EuchreState, seat: Int, legal: List): EuchreMove { @@ -47,19 +53,25 @@ class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) : */ private fun handValue(hand: List, trump: Suit): Int { var value = 0 + var trumpCount = 0 for (card in hand) { value += when { - isRightBower(card, trump) -> 9 - isLeftBower(card, trump) -> 7 - isTrumpCard(card, trump) -> 3 + (card.rank.order - Rank.NINE.order) / 2 + isRightBower(card, trump) -> 10 + isLeftBower(card, trump) -> 8 + isTrumpCard(card, trump) -> 4 + (card.rank.order - Rank.NINE.order) / 2 card.rank == Rank.ACE -> 3 card.rank == Rank.KING -> 1 else -> 0 } + if (isTrumpCard(card, trump)) trumpCount++ + } + // A void is a chance to trump in — but only while there is trump left + // to do it with. Counting voids on a trumpless hand was inflating + // exactly the weak hands that should be passing. + if (trumpCount >= 2) { + val sideSuits = Suit.entries.filter { it != trump } + value += sideSuits.count { suit -> hand.none { effectiveSuit(it, trump) == suit } } * 2 } - // A void in a side suit means an early chance to trump in. - val sideSuits = Suit.entries.filter { it != trump } - value += sideSuits.count { suit -> hand.none { effectiveSuit(it, trump) == suit } } * 2 return value } diff --git a/app/src/main/java/org/prolibertate/games/game/euchre/EuchreModel.kt b/app/src/main/java/org/prolibertate/games/game/euchre/EuchreModel.kt index 3087724..c55abc3 100644 --- a/app/src/main/java/org/prolibertate/games/game/euchre/EuchreModel.kt +++ b/app/src/main/java/org/prolibertate/games/game/euchre/EuchreModel.kt @@ -70,6 +70,12 @@ data class EuchreState( val turn: Int, val passes: Int, val trick: List, + /** + * The trick that has just been won, kept on the table so it can be read and + * then animated away. Cleared as soon as the next card is played. Winner is + * [lastTrickWinner]. + */ + val completedTrick: List = emptyList(), val leader: Int, val tricksWon: List, /** Two entries: team 0 is seats 0 and 2, team 1 is seats 1 and 3. */ diff --git a/app/src/main/java/org/prolibertate/games/game/euchre/EuchreRules.kt b/app/src/main/java/org/prolibertate/games/game/euchre/EuchreRules.kt index 3c7084a..3782f95 100644 --- a/app/src/main/java/org/prolibertate/games/game/euchre/EuchreRules.kt +++ b/app/src/main/java/org/prolibertate/games/game/euchre/EuchreRules.kt @@ -277,6 +277,7 @@ object EuchreRules : GameRules { leader = leader, turn = leader, trick = emptyList(), + completedTrick = emptyList(), passes = 0, ) } @@ -285,7 +286,8 @@ object EuchreRules : GameRules { val hands = state.hands.toMutableList() hands[seat] = hands[seat] - card val trick = state.trick + PlayedCard(seat, card) - val played = state.copy(hands = hands, trick = trick) + // Playing to a new trick sweeps the previous one off the table. + val played = state.copy(hands = hands, trick = trick, completedTrick = emptyList()) if (trick.size < state.activeSeatCount) { return played.copy(turn = nextActive(state, seat)) @@ -300,6 +302,9 @@ object EuchreRules : GameRules { val afterTrick = played.copy( trick = emptyList(), + // Held so the finished trick can be seen before it is cleared away, + // rather than the fourth card vanishing the instant it lands. + completedTrick = trick, tricksWon = tricksWon, leader = winner, turn = winner, diff --git a/app/src/main/java/org/prolibertate/games/net/MatchController.kt b/app/src/main/java/org/prolibertate/games/net/MatchController.kt index 1ed9c2e..7a1bc86 100644 --- a/app/src/main/java/org/prolibertate/games/net/MatchController.kt +++ b/app/src/main/java/org/prolibertate/games/net/MatchController.kt @@ -41,6 +41,12 @@ class MatchController( private val advanceIdle: (S) -> S? = { null }, /** Scaled by the animation-speed setting so the table paces itself. */ private val aiThinkingMillis: () -> Long = { 700L }, + /** + * Extra pause before the next move is made, given the current state. Lets a + * game hold a finished trick on the table long enough to be read before the + * next card lands on top of it. + */ + private val holdBeforeNextMove: (S) -> Long = { 0L }, ) { enum class Role { HOST, CLIENT } @@ -207,7 +213,7 @@ class MatchController( if (seatKinds[seat] != PlayerKind.AI) return // a person's turn - delay(aiThinkingMillis()) + delay(holdBeforeNextMove(current) + aiThinkingMillis()) val chosen = lock.withLock { val live = authoritative ?: return if (rules.currentSeat(live) != seat) return@withLock null diff --git a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt index aae16d7..770ec79 100644 --- a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt @@ -19,6 +19,7 @@ import org.prolibertate.games.game.sequence.SequenceState import org.prolibertate.games.net.MatchController import org.prolibertate.games.settings.Settings import org.prolibertate.games.ui.game.EuchreScreen +import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS import org.prolibertate.games.ui.game.SequenceScreen /** @@ -58,10 +59,24 @@ fun PlayScreen( } }, aiThinkingMillis = { settings.scaled(700L) }, + // Keep the finished trick on the table before the next + // card lands on top of it. + holdBeforeNextMove = { state -> + if (state.completedTrick.isNotEmpty()) { + settings.scaled(TRICK_HOLD_MILLIS) + } else { + 0L + } + }, ) } StartMatch(env, controller, route) - EuchreScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) + EuchreScreen( + controller = controller, + localSeat = route.localSeat, + trickHoldMillis = settings.scaled(TRICK_HOLD_MILLIS), + onExit = onExit, + ) } GameCatalog.SEQUENCE -> { diff --git a/app/src/main/java/org/prolibertate/games/ui/game/EuchreScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/EuchreScreen.kt index ed64229..3165a39 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/EuchreScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/EuchreScreen.kt @@ -1,5 +1,7 @@ package org.prolibertate.games.ui.game +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -10,6 +12,7 @@ import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState @@ -20,13 +23,22 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.LaunchedEffect 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.text.font.FontWeight +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.rotate +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import kotlin.math.roundToInt import org.prolibertate.games.game.euchre.CallTrump import org.prolibertate.games.game.euchre.Discard import org.prolibertate.games.game.euchre.EuchreMove @@ -53,6 +65,7 @@ import org.prolibertate.games.ui.theme.FeltGreenDark fun EuchreScreen( controller: MatchController, localSeat: Int, + trickHoldMillis: Long, onExit: () -> Unit, ) { val state by controller.state.collectAsState() @@ -86,7 +99,7 @@ fun EuchreScreen( .background(FeltGreenDark), contentAlignment = Alignment.Center, ) { - TrickArea(current, localSeat, cardWidth) + TrickArea(current, localSeat, cardWidth, trickHoldMillis) } notice?.let { @@ -168,8 +181,47 @@ private fun seatLabel(relative: Int): String = when (relative) { * turned to face them, so you can read a trick at a glance instead of matching * seat numbers to cards. */ +/** How long a completed trick rests on the table before being swept away. */ +const val TRICK_HOLD_MILLIS = 1_000L + +/** Duration of the sweep itself. */ +private const val TRICK_SWEEP_MILLIS = 450 + @Composable -private fun TrickArea(state: EuchreState, localSeat: Int, cardWidth: Dp) { +private fun TrickArea( + state: EuchreState, + localSeat: Int, + cardWidth: Dp, + trickHoldMillis: Long, +) { + // A finished trick stays put for a beat so it can be read, then slides off + // towards whoever won it. + val sweeping = state.trick.isEmpty() && state.completedTrick.isNotEmpty() + val cardsOnTable = if (sweeping) state.completedTrick else state.trick + + var sweepStarted by remember(state.completedTrick) { mutableStateOf(false) } + LaunchedEffect(state.completedTrick) { + if (state.completedTrick.isNotEmpty()) { + delay(trickHoldMillis) + sweepStarted = true + } + } + val sweep by animateFloatAsState( + targetValue = if (sweeping && sweepStarted) 1f else 0f, + animationSpec = tween(durationMillis = TRICK_SWEEP_MILLIS), + label = "trickSweep", + ) + + // Everything travels towards the winner's edge of the table together. + val winnerRelative = state.lastTrickWinner?.let { relativePosition(it, localSeat) } ?: 0 + val (dirX, dirY) = when (winnerRelative) { + 0 -> 0f to 1f + 1 -> -1f to 0f + 2 -> 0f to -1f + else -> 1f to 0f + } + val travelPx = with(LocalDensity.current) { (cardWidth * 2.5f).toPx() } + Box(modifier = Modifier.fillMaxSize().padding(4.dp)) { // The turn card sits in the middle of the table during bidding. @@ -187,7 +239,9 @@ private fun TrickArea(state: EuchreState, localSeat: Int, cardWidth: Dp) { } } - if (state.upCard == null && state.trick.isEmpty() && state.phase == EuchrePhase.PLAYING) { + if (state.upCard == null && cardsOnTable.isEmpty() && + state.phase == EuchrePhase.PLAYING + ) { Text( text = if (state.turn == localSeat) "Lead a card" else "Waiting…", color = MaterialTheme.colorScheme.onPrimary, @@ -195,7 +249,7 @@ private fun TrickArea(state: EuchreState, localSeat: Int, cardWidth: Dp) { ) } - state.trick.forEach { played -> + cardsOnTable.forEach { played -> val relative = relativePosition(played.seat, localSeat) val alignment = when (relative) { 0 -> Alignment.BottomCenter @@ -213,7 +267,15 @@ private fun TrickArea(state: EuchreState, localSeat: Int, cardWidth: Dp) { } Column( - modifier = Modifier.align(alignment), + modifier = Modifier + .align(alignment) + .offset { + IntOffset( + x = (dirX * travelPx * sweep).roundToInt(), + y = (dirY * travelPx * sweep).roundToInt(), + ) + } + .alpha(1f - sweep), horizontalAlignment = Alignment.CenterHorizontally, ) { PlayingCardView( @@ -229,6 +291,16 @@ private fun TrickArea(state: EuchreState, localSeat: Int, cardWidth: Dp) { } } + // Name the winner while their cards are still on the table. + if (sweeping && state.lastTrickWinner != null) { + Text( + text = "${seatLabel(winnerRelative)} takes it", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.align(Alignment.Center).alpha(1f - sweep), + ) + } + // Who is on the clock, shown where they sit rather than as a seat number. if (state.phase == EuchrePhase.PLAYING && state.trick.size < state.activeSeatCount) { val waitingOn = relativePosition(state.turn, localSeat) diff --git a/app/src/test/java/org/prolibertate/games/game/euchre/EuchreBiddingRateTest.kt b/app/src/test/java/org/prolibertate/games/game/euchre/EuchreBiddingRateTest.kt new file mode 100644 index 0000000..3dd7105 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/euchre/EuchreBiddingRateTest.kt @@ -0,0 +1,121 @@ +package org.prolibertate.games.game.euchre + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertTrue +import org.junit.Test +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig + +/** + * How often the AI takes the bid. + * + * This exists because an over-eager AI does not look like a tuning problem from + * the table — it looks like the game refusing to let you bid. If the three + * computer players order up nearly every deal, a human dealer is dragged into + * picking up the turn card again and again and never gets a say. + */ +class EuchreBiddingRateTest { + + private val json = Json { encodeDefaults = true } + + private fun config(seed: Long, options: EuchreOptions = EuchreOptions()) = TableConfig( + gameId = "euchre", + seats = (0 until 4).map { + PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it % 2) + }, + optionsJson = json.encodeToString(options), + seed = seed, + ) + + private data class Outcome( + val orderedUpInRoundOne: Boolean, + val reachedRoundTwo: Boolean, + val dealerForcedToPickUp: Boolean, + ) + + /** Runs the bidding for one deal with every seat played by the AI. */ + private fun bidOutDeal(seed: Long): Outcome { + val ai = EuchreAi() + var state = EuchreRules.initialState(config(seed)) + val dealer = state.dealer + var reachedRoundTwo = false + var guard = 0 + + while (guard++ < 40) { + when (state.phase) { + EuchrePhase.BID_ROUND_1 -> Unit + EuchrePhase.BID_ROUND_2 -> reachedRoundTwo = true + else -> break + } + val seat = EuchreRules.currentSeat(state) ?: break + val legal = EuchreRules.legalMoves(state, seat) + state = EuchreRules.applyMove(state, seat, ai.chooseMove(state, seat, legal)) + } + + // Reaching the discard step means somebody ordered it up and the dealer + // now has to take the card whether they like it or not. + val orderedUpInRoundOne = !reachedRoundTwo && state.trump != null + return Outcome( + orderedUpInRoundOne = orderedUpInRoundOne, + reachedRoundTwo = reachedRoundTwo, + dealerForcedToPickUp = orderedUpInRoundOne && state.maker != dealer, + ) + } + + @Test + fun `the ai does not order up on most deals`() { + val deals = 2000 + val outcomes = (0 until deals).map { bidOutDeal(it.toLong()) } + + val orderedUp = outcomes.count { it.orderedUpInRoundOne } + val forcedPickUp = outcomes.count { it.dealerForcedToPickUp } + val roundTwo = outcomes.count { it.reachedRoundTwo } + + val orderedUpRate = orderedUp * 100.0 / deals + val forcedRate = forcedPickUp * 100.0 / deals + println( + "ordered up in round 1: %.1f%% dealer forced to pick up: %.1f%% reached round 2: %.1f%%" + .format(orderedUpRate, forcedRate, roundTwo * 100.0 / deals) + ) + + // Ordering up should be a decision, not a formality. Real play turns the + // card down more often than not, so a majority of deals must survive + // round one. + assertTrue( + "AI orders up on %.1f%% of deals — far too eager".format(orderedUpRate), + orderedUpRate <= 55.0, + ) + assertTrue( + "AI almost never bids (%.1f%%) — too timid".format(orderedUpRate), + orderedUpRate >= 15.0, + ) + // The specific complaint: as dealer you keep being handed the turn card. + assertTrue( + "dealer is dragged into picking up on %.1f%% of deals".format(forcedRate), + forcedRate <= 45.0, + ) + } + + @Test + fun `going alone stays rare`() { + val ai = EuchreAi() + var alone = 0 + val deals = 1000 + repeat(deals) { seed -> + var state = EuchreRules.initialState(config(seed.toLong())) + var guard = 0 + while (state.trump == null && guard++ < 40) { + val seat = EuchreRules.currentSeat(state) ?: break + val legal = EuchreRules.legalMoves(state, seat) + if (legal.isEmpty()) break + state = EuchreRules.applyMove(state, seat, ai.chooseMove(state, seat, legal)) + } + if (state.aloneSeat != null) alone++ + } + val rate = alone * 100.0 / deals + println("went alone: %.1f%%".format(rate)) + assertTrue("going alone on %.1f%% of deals is too often".format(rate), rate <= 12.0) + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/euchre/EuchreRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/euchre/EuchreRulesTest.kt index 56b7e50..00d123f 100644 --- a/app/src/test/java/org/prolibertate/games/game/euchre/EuchreRulesTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/euchre/EuchreRulesTest.kt @@ -304,6 +304,45 @@ class EuchreRulesTest { assertEquals(0, finished.scores[0]) } + // -- Completed tricks --------------------------------------------------- + + @Test + fun `a finished trick stays on the table until the next card is played`() { + val trump = Suit.SPADES + val hands = listOf( + listOf(Card(Rank.ACE, Suit.SPADES), Card(Rank.NINE, Suit.HEARTS)), + listOf(Card(Rank.NINE, Suit.SPADES), Card(Rank.TEN, Suit.HEARTS)), + listOf(Card(Rank.TEN, Suit.SPADES), Card(Rank.QUEEN, Suit.HEARTS)), + listOf(Card(Rank.QUEEN, Suit.SPADES), Card(Rank.KING, Suit.HEARTS)), + ) + var state = playingState(hands = hands, trump = trump) + + // Play a full trick. + repeat(4) { + val seat = EuchreRules.currentSeat(state)!! + state = EuchreRules.applyMove(state, seat, PlayCard(state.hands[seat].first())) + } + + assertTrue("the live trick is cleared", state.trick.isEmpty()) + assertEquals("all four cards are held for display", 4, state.completedTrick.size) + assertEquals("seat 0 played the ace of trump", 0, state.lastTrickWinner) + + // The next card played sweeps it away. + val next = EuchreRules.currentSeat(state)!! + state = EuchreRules.applyMove(state, next, PlayCard(state.hands[next].first())) + assertTrue("held trick is cleared once play resumes", state.completedTrick.isEmpty()) + } + + @Test + fun `a fresh hand starts with nothing held on the table`() { + var state = EuchreRules.initialState(config()) + assertTrue(state.completedTrick.isEmpty()) + state = EuchreRules.applyMove(state, state.turn, OrderUp(alone = false)) + val discard = EuchreRules.legalMoves(state, state.dealer).first() as Discard + state = EuchreRules.applyMove(state, state.dealer, discard) + assertTrue("play begins with a clear table", state.completedTrick.isEmpty()) + } + // -- Full games --------------------------------------------------------- @Test From 60ddcdf67f33b70dff5b9e369f7d046a1e702cbd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 02:39:51 +0000 Subject: [PATCH 4/7] Caption Sequence jacks as WILD or REMOVE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A jack's power is carried by its artwork on a real deck — drawn in profile it shows one eye and removes a chip, drawn face-on it shows two and is wild. These cards render as rank and suit only, so J♦ and J♠ were indistinguishable in hand despite doing opposite things. Jacks in hand now carry the word on their face, and the hint under the hand says what tapping will do rather than the generic "tap a highlighted square", which was wrong for a one-eyed jack anyway — those target an opponent's chip, not a highlighted empty square. The caption is a general PlayingCardView option, so any later game whose cards carry meaning beyond rank and suit can use it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN --- .../games/ui/common/Components.kt | 21 ++++++++++++-- .../games/ui/game/SequenceScreen.kt | 29 +++++++++++++++++-- docs/RULES-sequence.md | 5 ++++ 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/prolibertate/games/ui/common/Components.kt b/app/src/main/java/org/prolibertate/games/ui/common/Components.kt index 3808e66..8ef3193 100644 --- a/app/src/main/java/org/prolibertate/games/ui/common/Components.kt +++ b/app/src/main/java/org/prolibertate/games/ui/common/Components.kt @@ -41,6 +41,12 @@ fun PlayingCardView( width: Dp = 64.dp, selected: Boolean = false, enabled: Boolean = true, + /** + * Short note printed on the face, for when the rank and suit alone do not + * say what the card does — a Sequence jack being the obvious case, since + * one-eyed and two-eyed jacks are only distinguishable by their artwork. + */ + caption: String? = null, onClick: (() -> Unit)? = null, ) { val ink = if (card.suit.isRed) CardRed else CardBlack @@ -61,6 +67,8 @@ fun PlayingCardView( ), contentAlignment = Alignment.Center, ) { + // Shrink the pips slightly when a caption has to share the face. + val pipScale = if (caption == null) 0.34f else 0.28f Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, @@ -69,13 +77,22 @@ fun PlayingCardView( text = card.rank.short, color = ink, fontWeight = FontWeight.Bold, - fontSize = (width.value * 0.34f).sp, + fontSize = (width.value * pipScale).sp, ) Text( text = card.suit.symbol, color = ink, - fontSize = (width.value * 0.34f).sp, + fontSize = (width.value * pipScale).sp, ) + if (caption != null) { + Text( + text = caption, + color = ink, + fontWeight = FontWeight.Bold, + fontSize = (width.value * 0.145f).sp, + maxLines = 1, + ) + } } } } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/SequenceScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/SequenceScreen.kt index 6843f97..afa4a21 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/SequenceScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/SequenceScreen.kt @@ -35,6 +35,8 @@ import androidx.compose.ui.unit.sp import org.prolibertate.games.game.cards.Card import org.prolibertate.games.game.sequence.BOARD_SIZE import org.prolibertate.games.game.sequence.ExchangeDeadCard +import org.prolibertate.games.game.sequence.isOneEyedJack +import org.prolibertate.games.game.sequence.isTwoEyedJack import org.prolibertate.games.game.sequence.NO_TEAM import org.prolibertate.games.game.sequence.PlaceChip import org.prolibertate.games.game.sequence.RemoveChip @@ -54,6 +56,17 @@ import org.prolibertate.games.ui.theme.TeamColours */ private val HighlightAmber = Color(0xFFFFC107) +/** + * What a jack does, printed on its face. On a real deck this is carried by the + * artwork — a jack shown in profile has one eye and removes a chip, one shown + * face-on has two and is wild — which a rank-and-suit card cannot convey. + */ +private fun jackCaption(card: Card): String? = when { + isTwoEyedJack(card) -> "WILD" + isOneEyedJack(card) -> "REMOVE" + else -> null +} + /** * The Sequence board. * @@ -294,6 +307,9 @@ private fun HandStrip( width = cardWidth, selected = card == selected, enabled = card in playable, + // A jack's power comes from its artwork, which these cards + // do not have, so spell it out on the face instead. + caption = jackCaption(card), onClick = { val dead = exchanges[card] if (dead != null) onExchange(dead) else onSelect(card) @@ -301,9 +317,18 @@ private fun HandStrip( ) } } - selected?.let { + selected?.let { card -> Text( - text = "Tap a highlighted square to play ${it.label}.", + text = when { + isTwoEyedJack(card) -> + "${card.label} is wild — tap any empty square." + + isOneEyedJack(card) -> + "${card.label} removes a chip — tap an opponent's, " + + "as long as it isn't part of a finished sequence." + + else -> "Tap a highlighted square to play ${card.label}." + }, style = MaterialTheme.typography.bodySmall, ) } diff --git a/docs/RULES-sequence.md b/docs/RULES-sequence.md index b09c36d..b68a82c 100644 --- a/docs/RULES-sequence.md +++ b/docs/RULES-sequence.md @@ -53,6 +53,11 @@ Play one card and place a chip on a matching empty square, then draw. - **Two-eyed jacks** (♦ and ♣) are wild: place a chip on any empty square. - **One-eyed jacks** (♥ and ♠) remove one opposing chip — but never a chip that is part of a completed sequence, and never your own team's chip. + +On a physical deck the difference is carried by the artwork: a jack drawn in +profile shows one eye, a jack drawn face-on shows two. These cards are drawn as +rank and suit only, so a jack in hand is captioned **WILD** or **REMOVE** +instead. - **Dead card — *toggle*, on by default.** If both squares printed with your card are already occupied, you may discard it and draw a replacement. This does not cost your turn, and you may do it once per turn. From ce83f7c2c4187f5d036da0b3e4d3b52277ff0b19 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 02:45:56 +0000 Subject: [PATCH 5/7] Hide unfinished games from production builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A release listed all eleven games with nine of them marked "Coming soon", so most of the menu was a dead end for anyone installing from the production channel. Production now lists only what can be played; dev builds still show everything, which is where seeing the roadmap is useful. The menu reads the installed APK's own version name, so this follows the build rather than the update channel selected for the future — a dev build shows the full list even if its owner has switched back to production for updates. CatalogTest pins both views, including that filtering never empties a category and strands its heading. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN --- README.md | 6 +- .../org/prolibertate/games/game/Catalog.kt | 20 +++++- .../java/org/prolibertate/games/ui/AppRoot.kt | 17 ++++- .../prolibertate/games/game/CatalogTest.kt | 65 +++++++++++++++++++ 4 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 app/src/test/java/org/prolibertate/games/game/CatalogTest.kt diff --git a/README.md b/README.md index 780e783..0e8985f 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,11 @@ The app shell is complete and two games are playable end to end: | **Playable** | Euchre, Sequence | | **Listed, not yet implemented** | Kaiser, President, Golf, Wizard, Crazy 8s, Mastermind, Backgammon, Chess, Checkers | -Unimplemented games appear in the menu marked *Coming soon*. Adding one means +Unimplemented games appear in the menu marked *Coming soon* **on dev builds +only** — a production release lists just the games that can actually be played, +so nobody installs it and taps into a dead end. The menu decides this from the +installed APK's own version name, so it follows the build rather than the +channel selected for future updates. Adding one means writing a `GameRules` implementation, a `GameAi`, and a screen, then flipping `available` in `GameCatalog` — the menu, lobby, networking, settings and OTA layers are game-agnostic and need no changes. diff --git a/app/src/main/java/org/prolibertate/games/game/Catalog.kt b/app/src/main/java/org/prolibertate/games/game/Catalog.kt index 1667c94..41f66c8 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -154,6 +154,22 @@ object GameCatalog { fun byId(id: String): GameDescriptor? = all.firstOrNull { it.id == id } - fun byCategory(category: GameCategory): List = - all.filter { it.category == category } + /** + * Games in a category. + * + * [includeComingSoon] is what separates the two update channels: a dev + * build lists everything, including the games that are still only entries + * in this table, so the roadmap is visible while working on it. A + * production build lists only what can actually be played, so nobody + * downloads a release and taps into a dead end. + */ + fun byCategory( + category: GameCategory, + includeComingSoon: Boolean = true, + ): List = all.filter { + it.category == category && (includeComingSoon || it.available) + } + + /** Everything currently playable, regardless of category. */ + val playable: List get() = all.filter { it.available } } diff --git a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt index 0d596c4..d886eae 100644 --- a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt +++ b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt @@ -39,6 +39,7 @@ import org.prolibertate.games.game.engine.TableConfig import org.prolibertate.games.net.LobbyController import org.prolibertate.games.settings.Settings import org.prolibertate.games.settings.SettingsRepository +import org.prolibertate.games.update.UpdateChannel import org.prolibertate.games.update.Updater /** Everything the screens need, assembled once by the activity. */ @@ -75,6 +76,12 @@ fun AppRoot(env: AppEnv) { val settings by env.settingsRepository.settings.collectAsState(initial = Settings()) var stack by remember { mutableStateOf(listOf(Route.Menu)) } + // Read from the installed APK's own version name, so this follows the build + // rather than the channel the user has selected for future updates. + val showComingSoon = remember { + env.updater.installedChannel() == UpdateChannel.DEV + } + fun push(route: Route) { stack = stack + route } @@ -94,6 +101,9 @@ fun AppRoot(env: AppEnv) { when (val route = stack.last()) { is Route.Menu -> MainMenuScreen( + // Dev builds show the games that are not finished yet; production + // releases list only what can actually be played. + showComingSoon = showComingSoon, onPickGame = { push(Route.Setup(it.id)) }, onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) }, onSettings = { push(Route.Settings) }, @@ -141,6 +151,7 @@ fun AppRoot(env: AppEnv) { @OptIn(ExperimentalMaterial3Api::class) @Composable fun MainMenuScreen( + showComingSoon: Boolean, onPickGame: (GameDescriptor) -> Unit, onJoinGame: () -> Unit, onSettings: () -> Unit, @@ -175,6 +186,10 @@ fun MainMenuScreen( modifier = Modifier.fillMaxSize(), ) { GameCategory.entries.forEach { category -> + val games = GameCatalog.byCategory(category, includeComingSoon = showComingSoon) + // Don't leave a heading stranded over nothing. + if (games.isEmpty()) return@forEach + item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) { Text( text = category.label, @@ -183,7 +198,7 @@ fun MainMenuScreen( modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), ) } - items(GameCatalog.byCategory(category)) { game -> + items(games) { game -> GameTile(game = game, onClick = { onPickGame(game) }) } } diff --git a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt new file mode 100644 index 0000000..6def631 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt @@ -0,0 +1,65 @@ +package org.prolibertate.games.game + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CatalogTest { + + @Test + fun `every game has a unique id`() { + val ids = GameCatalog.all.map { it.id } + assertEquals("ids must be unique", ids.size, ids.distinct().size) + ids.forEach { assertEquals(it, GameCatalog.byId(it)?.id) } + } + + @Test + fun `a production build lists only games that can be played`() { + GameCategory.entries.forEach { category -> + val shown = GameCatalog.byCategory(category, includeComingSoon = false) + assertTrue( + "$category offers an unfinished game in a release build", + shown.all { it.available }, + ) + } + } + + @Test + fun `a dev build lists everything including what is still coming`() { + val shown = GameCategory.entries.flatMap { + GameCatalog.byCategory(it, includeComingSoon = true) + } + assertEquals(GameCatalog.all.size, shown.size) + assertTrue("unfinished games are visible while developing", shown.any { !it.available }) + } + + @Test + fun `hiding unfinished games still leaves something to play in each category`() { + // A category filtered down to nothing would leave a stranded heading, + // and a menu with no playable game at all would be a broken release. + GameCategory.entries.forEach { category -> + assertFalse( + "$category has nothing playable in a release build", + GameCatalog.byCategory(category, includeComingSoon = false).isEmpty(), + ) + } + assertTrue(GameCatalog.playable.isNotEmpty()) + } + + @Test + fun `playable games are the ones with engines wired up`() { + assertEquals( + setOf(GameCatalog.EUCHRE, GameCatalog.SEQUENCE), + GameCatalog.playable.map { it.id }.toSet(), + ) + } + + @Test + fun `player counts are sane`() { + GameCatalog.all.forEach { game -> + assertTrue("${game.id} min players", game.minPlayers >= 2) + assertTrue("${game.id} max >= min", game.maxPlayers >= game.minPlayers) + } + } +} From dbe76c34c40abe8693d7f9c4d2c6b44a4d35fd4f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 02:52:55 +0000 Subject: [PATCH 6/7] Brand the app with the Wallace tartan and a Celtic splash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The launcher icon is a single square of the tartan, drawn as a vector so it stays sharp at any density and ships no PNGs. The adaptive icon's foreground is deliberately empty: the cloth is the icon. - Tartan.kt draws the sett as warp, then as weft at half opacity. That overlap is what gives the blended squares and reads as woven cloth rather than a printed grid, and it is reused by both the icon and the splash. - The app opens on the tartan under "Pro Libertate Games" in Uncial Antiqua, holds two seconds, then fades. It is an overlay rather than a destination, so the menu is already composed behind it and the fade reveals a live screen. The window background is set to a dark red so launch does not flash white before Compose draws. Two things to be aware of: - WallaceSett is an approximation. It reads as Wallace — bold red and black check with a narrow gold overstripe — but the registered threadcount could not be verified here, so the counts were chosen to look right. They are plain data and can be swapped for the registered ones without touching anything else. - Uncial Antiqua is bundled under the SIL Open Font License 1.1, with the full licence at docs/licenses/UncialAntiqua-OFL.txt. The OFL requires that to be distributed with the font, so it must not be separated from it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN --- README.md | 25 ++++ .../java/org/prolibertate/games/ui/AppRoot.kt | 35 ++++- .../org/prolibertate/games/ui/SplashScreen.kt | 125 ++++++++++++++++++ .../org/prolibertate/games/ui/theme/Tartan.kt | 97 ++++++++++++++ .../res/drawable/ic_launcher_background.xml | 52 +++++++- .../res/drawable/ic_launcher_foreground.xml | 13 +- app/src/main/res/font/uncial_antiqua.ttf | Bin 0 -> 61044 bytes app/src/main/res/mipmap/ic_launcher.xml | 28 ++-- app/src/main/res/mipmap/ic_launcher_round.xml | 28 ++-- app/src/main/res/values/colors.xml | 5 +- app/src/main/res/values/themes.xml | 1 + docs/licenses/UncialAntiqua-OFL.txt | 93 +++++++++++++ 12 files changed, 465 insertions(+), 37 deletions(-) create mode 100644 app/src/main/java/org/prolibertate/games/ui/SplashScreen.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/theme/Tartan.kt create mode 100644 app/src/main/res/font/uncial_antiqua.ttf create mode 100644 docs/licenses/UncialAntiqua-OFL.txt diff --git a/README.md b/README.md index 0e8985f..630df05 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,31 @@ them. - **Production** — stable builds, published from `main`. - **Dev** — preview builds, published from `dev` on every green CI run. +## Branding + +The launcher icon is a single square of the Wallace tartan, and the app opens on +a tartan splash carrying the name in a Celtic uncial hand for two seconds before +fading into the menu. + +The tartan is drawn, not an image: `ui/theme/Tartan.kt` lays the sett down as +warp and then as weft at half opacity, which is what produces the blended +squares instead of a flat grid. The icon is the same construction as a vector +drawable, so it stays sharp at every density and needs no PNGs. + +Two honest caveats: + +- **The sett is an approximation.** `WallaceSett` reproduces what the Wallace + tartan reads as — a bold red and black check divided by a narrow gold + overstripe — but the registered threadcount could not be verified, so the + numbers were chosen to look right rather than to be authoritative. They are + plain data; replacing them with the registered counts rebrands the whole app + and needs no other change. +- **The typeface is bundled under a licence.** Uncial Antiqua, © 2011 Brian J. + Bonislawsky DBA Astigmatic, Reserved Font Name "Uncial Antiqua", used under + the SIL Open Font License 1.1. The full licence is at + [docs/licenses/UncialAntiqua-OFL.txt](docs/licenses/UncialAntiqua-OFL.txt) and + must stay with the font — the OFL requires it to be distributed alongside. + ## Screen sizes There are no fixed orientations or hard-coded card sizes. Layouts measure the diff --git a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt index d886eae..8902894 100644 --- a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt +++ b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt @@ -82,11 +82,9 @@ fun AppRoot(env: AppEnv) { env.updater.installedChannel() == UpdateChannel.DEV } - fun push(route: Route) { - stack = stack + route - } + val push: (Route) -> Unit = { route -> stack = stack + route } - fun pop() { + val pop: () -> Unit = { if (stack.size > 1) { // Leaving a lobby or a table tears the networking down with it. when (stack.last()) { @@ -99,6 +97,35 @@ fun AppRoot(env: AppEnv) { BackHandler(enabled = stack.size > 1) { pop() } + var splashDone by remember { mutableStateOf(false) } + + Box(modifier = Modifier.fillMaxSize()) { + AppContent( + env = env, + settings = settings, + stack = stack, + showComingSoon = showComingSoon, + push = push, + pop = pop, + ) + + // Over the top, so the menu behind it is already composed when it fades. + if (!splashDone) { + SplashScreen(onFinished = { splashDone = true }) + } + } +} + +/** The navigation stack proper, kept separate so the splash can sit over it. */ +@Composable +private fun AppContent( + env: AppEnv, + settings: Settings, + stack: List, + showComingSoon: Boolean, + push: (Route) -> Unit, + pop: () -> Unit, +) { when (val route = stack.last()) { is Route.Menu -> MainMenuScreen( // Dev builds show the games that are not finished yet; production diff --git a/app/src/main/java/org/prolibertate/games/ui/SplashScreen.kt b/app/src/main/java/org/prolibertate/games/ui/SplashScreen.kt new file mode 100644 index 0000000..a7be877 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/SplashScreen.kt @@ -0,0 +1,125 @@ +package org.prolibertate.games.ui + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.alpha +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay +import org.prolibertate.games.R +import org.prolibertate.games.ui.theme.TartanBackground + +/** How long the splash holds at full opacity before fading. */ +private const val SPLASH_HOLD_MILLIS = 2_000L + +private const val SPLASH_FADE_MILLIS = 600 + +/** + * Uncial Antiqua — a Celtic uncial face, bundled under the SIL Open Font + * License. See docs/licenses/UncialAntiqua-OFL.txt. + */ +val CelticFontFamily = FontFamily(Font(R.font.uncial_antiqua)) + +/** + * The launch screen: Wallace tartan under the name of the house. + * + * Drawn as an overlay rather than a separate destination, so the menu is + * already composed and ready behind it and the fade reveals a live screen + * instead of a blank one. + */ +@Composable +fun SplashScreen( + onFinished: () -> Unit, + modifier: Modifier = Modifier, +) { + var fading by remember { mutableStateOf(false) } + val alpha by animateFloatAsState( + targetValue = if (fading) 0f else 1f, + animationSpec = tween(durationMillis = SPLASH_FADE_MILLIS), + label = "splashFade", + ) + + LaunchedEffect(Unit) { + delay(SPLASH_HOLD_MILLIS) + fading = true + delay(SPLASH_FADE_MILLIS.toLong()) + onFinished() + } + + BoxWithConstraints(modifier = modifier.fillMaxSize().alpha(alpha)) { + // Weave scale follows the screen so the cloth reads the same on a phone + // and a tablet rather than turning into a fine grid on a large display. + val threadWidth = (minOf(maxWidth, maxHeight).value * 0.012f).dp + TartanBackground( + modifier = Modifier.fillMaxSize(), + threadWidth = threadWidth, + ) + + // A scrim keeps the lettering legible over the busiest part of the sett. + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.45f)) + ) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "Pro Libertate", + style = TextStyle( + fontFamily = CelticFontFamily, + fontSize = (minOf(maxWidth, maxHeight).value * 0.13f).sp, + color = Color(0xFFF7EFD8), + textAlign = TextAlign.Center, + shadow = Shadow( + color = Color.Black, + offset = Offset(0f, 3f), + blurRadius = 10f, + ), + ), + ) + Text( + text = "Games", + style = TextStyle( + fontFamily = CelticFontFamily, + fontSize = (minOf(maxWidth, maxHeight).value * 0.13f).sp, + color = Color(0xFFF2C200), + textAlign = TextAlign.Center, + shadow = Shadow( + color = Color.Black, + offset = Offset(0f, 3f), + blurRadius = 10f, + ), + ), + ) + } + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/theme/Tartan.kt b/app/src/main/java/org/prolibertate/games/ui/theme/Tartan.kt new file mode 100644 index 0000000..a5024ad --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/theme/Tartan.kt @@ -0,0 +1,97 @@ +package org.prolibertate.games.ui.theme + +import androidx.compose.foundation.Canvas +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** One band of a tartan sett, measured in threads. */ +data class TartanStripe(val color: Color, val threads: Int) + +val WallaceRed = Color(0xFFB3121B) +val WallaceBlack = Color(0xFF111111) +val WallaceGold = Color(0xFFF2C200) + +/** + * The Wallace sett. + * + * NOTE: this is an approximation, not a certified threadcount. It reproduces + * what the Wallace tartan reads as — a bold red and black check divided by a + * narrow gold overstripe — but the exact thread counts held by the Scottish + * Register of Tartans could not be verified from here, so the numbers below + * are chosen to look right rather than to be authoritative. They are plain + * data: replacing them with the registered counts changes the whole app's + * branding and needs no other edit. + */ +val WallaceSett: List = listOf( + TartanStripe(WallaceRed, 40), + TartanStripe(WallaceBlack, 40), + TartanStripe(WallaceGold, 4), +) + +/** + * Draws a tartan by laying the sett down vertically as warp, then across as + * weft at half opacity. The overlap is what produces the blended squares and + * the sense of a woven cloth rather than a printed grid — red warp under black + * weft comes out a darker red, exactly as it does on the loom. + */ +fun DrawScope.drawTartan( + sett: List = WallaceSett, + threadSize: Float, +) { + if (sett.isEmpty() || threadSize <= 0f) return + val repeat = sett.sumOf { it.threads } * threadSize + if (repeat <= 0f) return + + // Warp: full-height bands. + var x = 0f + while (x < size.width) { + var offset = x + for (stripe in sett) { + val width = stripe.threads * threadSize + drawRect( + color = stripe.color, + topLeft = Offset(offset, 0f), + size = Size(width, size.height), + ) + offset += width + } + x += repeat + } + + // Weft: full-width bands, half opacity so the warp shows through. + var y = 0f + while (y < size.height) { + var offset = y + for (stripe in sett) { + val height = stripe.threads * threadSize + drawRect( + color = stripe.color.copy(alpha = 0.5f), + topLeft = Offset(0f, offset), + size = Size(size.width, height), + ) + offset += height + } + y += repeat + } +} + +/** + * A tartan fill. [threadWidth] sets the weave scale, so the same cloth can be + * a fine weave on a phone and a coarse one on a tablet without redrawing it. + */ +@Composable +fun TartanBackground( + modifier: Modifier = Modifier, + sett: List = WallaceSett, + threadWidth: Dp = 2.dp, +) { + Canvas(modifier = modifier) { + drawTartan(sett = sett, threadSize = threadWidth.toPx()) + } +} diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index a1cb143..09262d8 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -1,11 +1,59 @@ + - + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 5c8a39a..0e5a48e 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,14 +1,15 @@ + - - + android:fillColor="#00000000" + android:pathData="M0,0h108v108h-108z" /> diff --git a/app/src/main/res/font/uncial_antiqua.ttf b/app/src/main/res/font/uncial_antiqua.ttf new file mode 100644 index 0000000000000000000000000000000000000000..05ded2146f69c877b2d2a8f59e1fc63e77afec81 GIT binary patch literal 61044 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap/ic_launcher.xml b/app/src/main/res/mipmap/ic_launcher.xml index 8ed1d4d..d1b3320 100644 --- a/app/src/main/res/mipmap/ic_launcher.xml +++ b/app/src/main/res/mipmap/ic_launcher.xml @@ -1,17 +1,21 @@ - + - - - + android:viewportWidth="84" + android:viewportHeight="84"> + + + + + + + + + + + diff --git a/app/src/main/res/mipmap/ic_launcher_round.xml b/app/src/main/res/mipmap/ic_launcher_round.xml index 69e9235..d1b3320 100644 --- a/app/src/main/res/mipmap/ic_launcher_round.xml +++ b/app/src/main/res/mipmap/ic_launcher_round.xml @@ -1,17 +1,21 @@ - + - - - + android:viewportWidth="84" + android:viewportHeight="84"> + + + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 48d3808..97c6599 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,6 +1,9 @@ - #1B5E20 + #B3121B #FF000000 #FFFFFFFF + + #4A0B10 diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index bf1e235..492b38a 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -5,5 +5,6 @@ diff --git a/docs/licenses/UncialAntiqua-OFL.txt b/docs/licenses/UncialAntiqua-OFL.txt new file mode 100644 index 0000000..d252554 --- /dev/null +++ b/docs/licenses/UncialAntiqua-OFL.txt @@ -0,0 +1,93 @@ +Copyright (c) 2011 by Brian J. Bonislawsky DBA Astigmatic (AOETI) (astigma@astigmatic.com), with Reserved Font Name "Uncial Antiqua" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. From b49bfc0fe6a957143e95ef3b75675c72680139f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 02:59:32 +0000 Subject: [PATCH 7/7] Fix splash screen constraint scoping BoxWithConstraints exposes maxWidth and maxHeight on its own scope, but inside Column the implicit receiver becomes ColumnScope and they are no longer reachable, so the two font-size expressions failed to compile. Read the constraints once into locals at the top of the BoxWithConstraints body, which is what the Euchre and Sequence screens already do. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN --- .../java/org/prolibertate/games/ui/SplashScreen.kt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/prolibertate/games/ui/SplashScreen.kt b/app/src/main/java/org/prolibertate/games/ui/SplashScreen.kt index a7be877..4edff5c 100644 --- a/app/src/main/java/org/prolibertate/games/ui/SplashScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/SplashScreen.kt @@ -70,9 +70,15 @@ fun SplashScreen( } BoxWithConstraints(modifier = modifier.fillMaxSize().alpha(alpha)) { + // Read the constraints here and hold them in locals: inside Column the + // implicit receiver becomes ColumnScope, and maxWidth/maxHeight are no + // longer in scope. + val shortestSide = minOf(maxWidth, maxHeight).value + // Weave scale follows the screen so the cloth reads the same on a phone // and a tablet rather than turning into a fine grid on a large display. - val threadWidth = (minOf(maxWidth, maxHeight).value * 0.012f).dp + val threadWidth = (shortestSide * 0.012f).dp + val titleSize = (shortestSide * 0.13f).sp TartanBackground( modifier = Modifier.fillMaxSize(), threadWidth = threadWidth, @@ -96,7 +102,7 @@ fun SplashScreen( text = "Pro Libertate", style = TextStyle( fontFamily = CelticFontFamily, - fontSize = (minOf(maxWidth, maxHeight).value * 0.13f).sp, + fontSize = titleSize, color = Color(0xFFF7EFD8), textAlign = TextAlign.Center, shadow = Shadow( @@ -110,7 +116,7 @@ fun SplashScreen( text = "Games", style = TextStyle( fontFamily = CelticFontFamily, - fontSize = (minOf(maxWidth, maxHeight).value * 0.13f).sp, + fontSize = titleSize, color = Color(0xFFF2C200), textAlign = TextAlign.Center, shadow = Shadow(