diff --git a/README.md b/README.md index 780e783..630df05 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. @@ -53,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/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/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/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..8902894 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,11 +76,15 @@ fun AppRoot(env: AppEnv) { val settings by env.settingsRepository.settings.collectAsState(initial = Settings()) var stack by remember { mutableStateOf(listOf(Route.Menu)) } - fun push(route: Route) { - stack = stack + route + // 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 pop() { + val push: (Route) -> Unit = { route -> stack = stack + route } + + val pop: () -> Unit = { if (stack.size > 1) { // Leaving a lobby or a table tears the networking down with it. when (stack.last()) { @@ -92,8 +97,40 @@ 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 + // 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) }, @@ -107,7 +144,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 +155,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)) @@ -141,6 +178,7 @@ fun AppRoot(env: AppEnv) { @OptIn(ExperimentalMaterial3Api::class) @Composable fun MainMenuScreen( + showComingSoon: Boolean, onPickGame: (GameDescriptor) -> Unit, onJoinGame: () -> Unit, onSettings: () -> Unit, @@ -175,6 +213,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 +225,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/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/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() 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..4edff5c --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/SplashScreen.kt @@ -0,0 +1,131 @@ +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)) { + // 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 = (shortestSide * 0.012f).dp + val titleSize = (shortestSide * 0.13f).sp + 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 = titleSize, + 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 = titleSize, + 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/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/EuchreScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/EuchreScreen.kt index fc4d513..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,13 +1,18 @@ 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 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 +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState @@ -18,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 @@ -51,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() @@ -84,7 +99,7 @@ fun EuchreScreen( .background(FeltGreenDark), contentAlignment = Alignment.Center, ) { - TrickArea(current, localSeat, cardWidth) + TrickArea(current, localSeat, cardWidth, trickHoldMillis) } notice?.let { @@ -166,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. @@ -185,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, @@ -193,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 @@ -211,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( @@ -227,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) @@ -242,6 +316,7 @@ private fun TrickArea(state: EuchreState, localSeat: Int, cardWidth: Dp) { } } +@OptIn(ExperimentalLayoutApi::class) @Composable private fun BiddingControls( state: EuchreState, @@ -260,6 +335,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 +359,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 +390,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") } } } 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/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 0000000..05ded21 Binary files /dev/null and b/app/src/main/res/font/uncial_antiqua.ttf differ 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/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) + } + } +} 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 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. 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.