Add app shell with Euchre and Sequence, LAN/Bluetooth play

First vertical slice: the whole shell plus two games playable end to end,
so every layer is exercised before the other nine games are built on it.

Games
- Euchre: bowers, ordering up, dealer discard, going alone, stick the
  dealer, and full scoring. Rule variants (deck size, points to win,
  stick the dealer, going alone) are toggles in game setup.
- Sequence: two-eyed and one-eyed jacks, free corners, locked sequences,
  the shared-chip limit, and dead-card exchange. Teams, players per team
  and the sequence target are toggles.
- The board layout in SequenceBoard is generated, not the commercial
  board — it holds every structural property the rules need, but does not
  match a physical board. Documented in docs/RULES-sequence.md.

Multiplayer
- One transport abstraction over newline-delimited JSON, implemented
  twice: LAN via NSD discovery and TCP, Bluetooth via Classic RFCOMM.
  A lobby advertises on both at once, so a table can mix them.
- Host-authoritative: clients send move intents, the host re-validates
  every one against the rules, and each client receives a state redacted
  for its own seat so hidden information never crosses the wire.
- Seats start as AI and are handed to people as they join, which is what
  makes any mix of humans and AI work without a separate code path.

Shell
- Main menu lists all eleven games; the nine without engines are marked
  coming soon and are driven entirely by GameCatalog.
- Settings: sound, animation speed (one multiplier drives card movement
  and AI thinking time), and OTA updates from the latest GitHub release.
- Layouts measure available space rather than assuming an orientation.

The game package is free of Android types, so the rules engines and AI
run under plain JUnit; 43 tests cover ranking, following suit, scoring,
sequence detection, redaction, wire round-trips and full random and AI
playthroughs.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN
This commit is contained in:
Claude
2026-08-05 22:37:05 +00:00
parent 4312616efa
commit 487c68419e
54 changed files with 6487 additions and 0 deletions
+71
View File
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- OTA updates from GitHub Releases, plus LAN play over TCP sockets. -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Network Service Discovery needs multicast to see hosts on the LAN. -->
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<!-- Lets the app hand a downloaded APK to the system installer. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Bluetooth. Android 12 (API 31) split the old catch-all permissions into
explicit SCAN/CONNECT/ADVERTISE, so the legacy pair is capped below 31
and the new trio is requested at runtime above it. neverForLocation
tells the system we aren't deriving physical location from scans, which
avoids dragging in the location permission on 12+. -->
<uses-permission android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"
tools:targetApi="s" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE"
tools:targetApi="s" />
<!-- Pre-31, Bluetooth discovery is gated behind location access. -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
<!-- Both radios are optional: the app is fully playable against AI without them. -->
<uses-feature android:name="android.hardware.bluetooth" android:required="false" />
<uses-feature android:name="android.hardware.wifi" android:required="false" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.ProLibertateGames">
<!-- No fixed orientation: the UI adapts to portrait, landscape and
tablet widths. configChanges keeps Compose state across rotation
instead of tearing the activity down mid-hand. -->
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|density">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Serves the downloaded APK to the package installer via a content URI. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,77 @@
package org.prolibertate.games
import android.Manifest
import android.os.Build
import android.os.Bundle
import android.provider.Settings as AndroidSettings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.lifecycle.lifecycleScope
import org.prolibertate.games.net.LobbyController
import org.prolibertate.games.settings.SettingsRepository
import org.prolibertate.games.ui.AppEnv
import org.prolibertate.games.ui.AppRoot
import org.prolibertate.games.ui.theme.ProLibertateTheme
import org.prolibertate.games.update.Updater
import java.util.UUID
class MainActivity : ComponentActivity() {
private lateinit var env: AppEnv
/**
* Bluetooth needs runtime consent from Android 12 onwards. The result is
* ignored deliberately: refusing simply means the Bluetooth transport
* reports itself unavailable and the lobby falls back to Wi-Fi.
*/
private val bluetoothPermissions =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
env = AppEnv(
settingsRepository = SettingsRepository(applicationContext),
updater = Updater(this),
lobby = LobbyController(applicationContext, lifecycleScope),
peerId = stablePeerId(),
requestBluetoothPermissions = ::requestBluetoothPermissions,
)
setContent {
ProLibertateTheme {
AppRoot(env)
}
}
}
private fun requestBluetoothPermissions() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
bluetoothPermissions.launch(
arrayOf(
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_ADVERTISE,
)
)
}
}
/**
* Identifies this device to a host for the length of an install. ANDROID_ID
* is per-app-signing-key and survives restarts, which is all a lobby needs
* to tell two phones apart.
*/
private fun stablePeerId(): String {
val androidId = runCatching {
AndroidSettings.Secure.getString(contentResolver, AndroidSettings.Secure.ANDROID_ID)
}.getOrNull()
return androidId ?: UUID.randomUUID().toString()
}
override fun onDestroy() {
super.onDestroy()
if (::env.isInitialized) env.lobby.stop()
}
}
@@ -0,0 +1,159 @@
package org.prolibertate.games.game
/**
* The catalogue the main menu is built from.
*
* Every game the app intends to ship appears here, whether or not its rules
* engine exists yet. [GameDescriptor.available] is what the menu keys off to
* decide between "play" and "coming soon", so adding a game is a matter of
* flipping that flag once its engine and screen land.
*/
enum class GameCategory(val label: String) {
CARD("Playing Card Games"),
BOARD("Board Games"),
}
data class GameDescriptor(
val id: String,
val title: String,
val category: GameCategory,
val minPlayers: Int,
val maxPlayers: Int,
/** True when players are grouped into partnerships rather than playing solo. */
val teamBased: Boolean,
val blurb: String,
val available: Boolean,
)
object GameCatalog {
const val EUCHRE = "euchre"
const val KAISER = "kaiser"
const val PRESIDENT = "president"
const val GOLF = "golf"
const val WIZARD = "wizard"
const val CRAZY_EIGHTS = "crazy8s"
const val SEQUENCE = "sequence"
const val MASTERMIND = "mastermind"
const val BACKGAMMON = "backgammon"
const val CHESS = "chess"
const val CHECKERS = "checkers"
val all: List<GameDescriptor> = listOf(
GameDescriptor(
id = EUCHRE,
title = "Euchre",
category = GameCategory.CARD,
minPlayers = 4,
maxPlayers = 4,
teamBased = true,
blurb = "Four players, two partnerships, 24-card deck. Bowers are trump.",
available = true,
),
GameDescriptor(
id = KAISER,
title = "Kaiser",
category = GameCategory.CARD,
minPlayers = 4,
maxPlayers = 4,
teamBased = true,
blurb = "Prairie trick-taking with bidding. The 5 of hearts and 3 of spades decide it.",
available = false,
),
GameDescriptor(
id = PRESIDENT,
title = "President",
category = GameCategory.CARD,
minPlayers = 3,
maxPlayers = 7,
teamBased = false,
blurb = "Shed your hand first and rule the table. Last one out serves the drinks.",
available = false,
),
GameDescriptor(
id = GOLF,
title = "Golf",
category = GameCategory.CARD,
minPlayers = 2,
maxPlayers = 6,
teamBased = false,
blurb = "Lowest score wins over nine holes. Match pairs to cancel them out.",
available = false,
),
GameDescriptor(
id = WIZARD,
title = "Wizard",
category = GameCategory.CARD,
minPlayers = 3,
maxPlayers = 6,
teamBased = false,
blurb = "Bid your tricks exactly. Wizards always win, jesters always lose.",
available = false,
),
GameDescriptor(
id = CRAZY_EIGHTS,
title = "Crazy 8s",
category = GameCategory.CARD,
minPlayers = 2,
maxPlayers = 6,
teamBased = false,
blurb = "Match rank or suit, and eights change everything.",
available = false,
),
GameDescriptor(
id = SEQUENCE,
title = "Sequence",
category = GameCategory.BOARD,
minPlayers = 2,
maxPlayers = 12,
teamBased = true,
blurb = "Play a card, claim its square, and build five in a row.",
available = true,
),
GameDescriptor(
id = MASTERMIND,
title = "Mastermind",
category = GameCategory.BOARD,
minPlayers = 2,
maxPlayers = 2,
teamBased = false,
blurb = "Break the hidden code from black and white peg clues.",
available = false,
),
GameDescriptor(
id = BACKGAMMON,
title = "Backgammon",
category = GameCategory.BOARD,
minPlayers = 2,
maxPlayers = 2,
teamBased = false,
blurb = "Race your checkers home, and hit your opponent on the way.",
available = false,
),
GameDescriptor(
id = CHESS,
title = "Chess",
category = GameCategory.BOARD,
minPlayers = 2,
maxPlayers = 2,
teamBased = false,
blurb = "The old game. Full legal moves, castling, en passant, promotion.",
available = false,
),
GameDescriptor(
id = CHECKERS,
title = "Checkers",
category = GameCategory.BOARD,
minPlayers = 2,
maxPlayers = 2,
teamBased = false,
blurb = "Forced captures, kings, and the long diagonal.",
available = false,
),
)
fun byId(id: String): GameDescriptor? = all.firstOrNull { it.id == id }
fun byCategory(category: GameCategory): List<GameDescriptor> =
all.filter { it.category == category }
}
@@ -0,0 +1,97 @@
package org.prolibertate.games.game.cards
import kotlinx.serialization.Serializable
import kotlin.random.Random
@Serializable
enum class Suit(val symbol: String, val isRed: Boolean) {
CLUBS("♣", false),
DIAMONDS("♦", true),
HEARTS("♥", true),
SPADES("♠", false);
/** Euchre's left bower lives in the suit of the same colour. */
fun sameColour(): Suit = when (this) {
CLUBS -> SPADES
SPADES -> CLUBS
DIAMONDS -> HEARTS
HEARTS -> DIAMONDS
}
}
@Serializable
enum class Rank(val short: String, val order: Int) {
TWO("2", 2),
THREE("3", 3),
FOUR("4", 4),
FIVE("5", 5),
SIX("6", 6),
SEVEN("7", 7),
EIGHT("8", 8),
NINE("9", 9),
TEN("10", 10),
JACK("J", 11),
QUEEN("Q", 12),
KING("K", 13),
ACE("A", 14),
}
@Serializable
data class Card(val rank: Rank, val suit: Suit) {
/** Compact form used in logs and in the Sequence board layout table. */
val code: String get() = "${rank.short}${suit.name.first()}"
val label: String get() = "${rank.short}${suit.symbol}"
override fun toString(): String = label
companion object {
/** Parses the [code] form, e.g. "10H", "AS", "9D". */
fun parse(code: String): Card {
val suitChar = code.last()
val rankPart = code.dropLast(1)
val suit = Suit.entries.first { it.name.first() == suitChar }
val rank = Rank.entries.first { it.short == rankPart }
return Card(rank, suit)
}
}
}
object Decks {
/** Standard 52-card deck. */
fun standard52(): List<Card> =
Suit.entries.flatMap { suit -> Rank.entries.map { rank -> Card(rank, suit) } }
/**
* Euchre decks. 24 cards (9 through ace) is the common game; 32 cards
* (7 through ace) is a widespread variant that lengthens the hand.
*/
fun euchre(size: Int): List<Card> {
val lowest = when (size) {
24 -> Rank.NINE
32 -> Rank.SEVEN
else -> throw IllegalArgumentException("Unsupported Euchre deck size: $size")
}
return Suit.entries.flatMap { suit ->
Rank.entries.filter { it.order >= lowest.order }.map { rank -> Card(rank, suit) }
}
}
/** Two full decks shuffled together, as Sequence uses. */
fun double52(): List<Card> = standard52() + standard52()
}
/**
* Deterministic shuffle. Every shuffle in the app runs through a seeded
* [Random] so a networked hand can be reproduced from its seed and so tests
* are repeatable — the host generates the seed and ships it with the state.
*/
fun <T> List<T>.shuffledWith(random: Random): List<T> = toMutableList().apply {
for (i in indices.reversed()) {
val j = random.nextInt(i + 1)
val tmp = this[i]
this[i] = this[j]
this[j] = tmp
}
}
@@ -0,0 +1,87 @@
package org.prolibertate.games.game.engine
import kotlinx.serialization.Serializable
@Serializable
enum class PlayerKind {
/** A person on this device. */
HUMAN_LOCAL,
/** A person on another device, reached over LAN or Bluetooth. */
HUMAN_REMOTE,
/** Driven by the host's AI. */
AI,
}
@Serializable
data class PlayerSlot(
val seat: Int,
val name: String,
val kind: PlayerKind,
/** Partnership index. Solo games put every seat on its own team. */
val team: Int,
/** Identifies the remote device for [PlayerKind.HUMAN_REMOTE] seats. */
val peerId: String? = null,
)
@Serializable
data class TableConfig(
val gameId: String,
val seats: List<PlayerSlot>,
/** Game-specific options, serialised by the owning rules implementation. */
val optionsJson: String,
/** Shared shuffle seed so a hand is reproducible from its state. */
val seed: Long,
)
/**
* A game's rules, expressed as a pure state machine.
*
* Implementations must be free of Android dependencies and free of side
* effects: [applyMove] returns a new state rather than mutating. That is what
* lets the same code drive the local AI, the host's authoritative copy, and
* the unit tests.
*/
interface GameRules<S : Any, M : Any> {
val gameId: String
fun initialState(config: TableConfig): S
/** The seat expected to move, or null when nobody is on the clock. */
fun currentSeat(state: S): Int?
fun legalMoves(state: S, seat: Int): List<M>
/**
* Applies [move] on behalf of [seat].
*
* @throws IllegalArgumentException if the move is not currently legal —
* the host validates every intent that arrives over the network this way.
*/
fun applyMove(state: S, seat: Int, move: M): S
fun isFinished(state: S): Boolean
fun summary(state: S): String
/**
* Strips information [seat] is not entitled to see.
*
* The host holds the true state and sends each client only its own view,
* so an opponent's hand never crosses the wire in the first place. Any
* hidden-information game must implement this meaningfully.
*/
fun viewFor(state: S, seat: Int): S
fun encodeState(state: S): String
fun decodeState(json: String): S
fun encodeMove(move: M): String
fun decodeMove(json: String): M
}
/** Chooses moves for unoccupied seats. Always runs on the host. */
interface GameAi<S : Any, M : Any> {
fun chooseMove(state: S, seat: Int, legal: List<M>): M
}
@@ -0,0 +1,175 @@
package org.prolibertate.games.game.euchre
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.engine.GameAi
/**
* A heuristic Euchre opponent.
*
* It does not look at anybody else's cards and it does not search — it counts
* trump, respects its partner, and plays the cheapest card that does the job.
* That is enough to be a reasonable table opponent without pretending to be a
* solver.
*/
class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
GameAi<EuchreState, EuchreMove> {
enum class Difficulty(val bidThreshold: Int, val aloneThreshold: Int) {
/** Bids loosely and plays straightforwardly. */
EASY(bidThreshold = 7, aloneThreshold = 26),
NORMAL(bidThreshold = 9, aloneThreshold = 24),
/** Bids tighter and is harder to sneak a trick past. */
CAUTIOUS(bidThreshold = 11, aloneThreshold = 22),
}
override fun chooseMove(state: EuchreState, seat: Int, legal: List<EuchreMove>): EuchreMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
return when (state.phase) {
EuchrePhase.BID_ROUND_1 -> chooseBidRound1(state, seat, legal)
EuchrePhase.BID_ROUND_2 -> chooseBidRound2(state, seat, legal)
EuchrePhase.DEALER_DISCARD -> chooseDiscard(state, legal)
EuchrePhase.PLAYING -> choosePlay(state, seat, legal)
else -> legal.first()
}
}
// -----------------------------------------------------------------------
// Bidding
// -----------------------------------------------------------------------
/**
* Rates a hand for a candidate trump suit. Bowers and trump length carry
* the hand; off-suit aces are worth a trick more often than not.
*/
private fun handValue(hand: List<Card>, trump: Suit): Int {
var value = 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
card.rank == Rank.ACE -> 3
card.rank == Rank.KING -> 1
else -> 0
}
}
// 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
}
private fun chooseBidRound1(
state: EuchreState,
seat: Int,
legal: List<EuchreMove>,
): EuchreMove {
val upCard = state.upCard ?: return Pass
val trump = upCard.suit
var hand = state.hands[seat]
// If our partner is the dealer they gain the turn card, and if we are
// the dealer we gain it ourselves — both make the hand stronger than it
// looks. Ordering up into an opponent dealer instead hands them a card.
if (seat == state.dealer) hand = hand + upCard
var value = handValue(hand, trump)
if (partnerOf(seat) == state.dealer) value += 2
if (seat != state.dealer && partnerOf(seat) != state.dealer) value -= 2
val alone = OrderUp(alone = true)
if (value >= difficulty.aloneThreshold && legal.contains(alone)) return alone
val order = OrderUp(alone = false)
if (value >= difficulty.bidThreshold && legal.contains(order)) return order
return if (legal.contains(Pass)) Pass else legal.first()
}
private fun chooseBidRound2(
state: EuchreState,
seat: Int,
legal: List<EuchreMove>,
): EuchreMove {
val hand = state.hands[seat]
val callable = legal.filterIsInstance<CallTrump>()
if (callable.isEmpty()) return legal.first()
val best = callable
.filter { !it.alone }
.maxByOrNull { handValue(hand, it.suit) }
?: callable.first()
val bestValue = handValue(hand, best.suit)
val alone = callable.firstOrNull { it.alone && it.suit == best.suit }
if (alone != null && bestValue >= difficulty.aloneThreshold) return alone
if (bestValue >= difficulty.bidThreshold) return best
// Stuck as dealer with a bad hand: name the least-bad suit anyway.
return if (legal.contains(Pass)) Pass else best
}
private fun chooseDiscard(state: EuchreState, legal: List<EuchreMove>): EuchreMove {
val trump = requireNotNull(state.trump)
val discards = legal.filterIsInstance<Discard>()
// Prefer to void a side suit entirely; otherwise throw the lowest card.
val hand = state.hands[state.dealer]
val suitCounts = hand.groupingBy { effectiveSuit(it, trump) }.eachCount()
return discards.minByOrNull { discard ->
val card = discard.card
val suit = effectiveSuit(card, trump)
val trumpPenalty = if (suit == trump) 1000 else 0
val singletonBonus = if (suitCounts[suit] == 1 && card.rank != Rank.ACE) -20 else 0
trumpPenalty + singletonBonus + card.rank.order
} ?: discards.first()
}
// -----------------------------------------------------------------------
// Card play
// -----------------------------------------------------------------------
private fun choosePlay(
state: EuchreState,
seat: Int,
legal: List<EuchreMove>,
): EuchreMove {
val trump = requireNotNull(state.trump)
val cards = legal.filterIsInstance<PlayCard>().map { it.card }
if (cards.size == 1) return PlayCard(cards.first())
if (state.trick.isEmpty()) return PlayCard(chooseLead(cards, trump))
val ledSuit = effectiveSuit(state.trick.first().card, trump)
val bestSoFar = state.trick.maxBy { trickStrength(it.card, trump, ledSuit) }
val partnerWinning = partnerOf(bestSoFar.seat) == seat
val lastToPlay = state.trick.size == state.activeSeatCount - 1
// Partner already has it and we are last: never waste a good card.
if (partnerWinning && lastToPlay) return PlayCard(lowest(cards, trump, ledSuit))
val bestStrength = trickStrength(bestSoFar.card, trump, ledSuit)
val winners = cards.filter { trickStrength(it, trump, ledSuit) > bestStrength }
return when {
// Take it as cheaply as we can.
winners.isNotEmpty() && (!partnerWinning || !lastToPlay) ->
PlayCard(winners.minBy { trickStrength(it, trump, ledSuit) })
else -> PlayCard(lowest(cards, trump, ledSuit))
}
}
private fun chooseLead(cards: List<Card>, trump: Suit): Card {
// Right bower up front pulls trump and protects the left.
cards.firstOrNull { isRightBower(it, trump) }?.let { return it }
// Off-suit aces are most likely to survive early.
cards.filter { !isTrumpCard(it, trump) && it.rank == Rank.ACE }
.maxByOrNull { it.rank.order }
?.let { return it }
val offSuit = cards.filter { !isTrumpCard(it, trump) }
if (offSuit.isNotEmpty()) return offSuit.maxBy { it.rank.order }
return cards.maxBy { trumpStrength(it, trump) }
}
private fun lowest(cards: List<Card>, trump: Suit, ledSuit: Suit): Card =
cards.minBy { trickStrength(it, trump, ledSuit).takeIf { s -> s > 0 } ?: it.rank.order }
}
@@ -0,0 +1,154 @@
package org.prolibertate.games.game.euchre
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
/**
* Rule toggles. Euchre is played differently in almost every kitchen, so the
* variations that actually change play are surfaced in the game setup screen
* rather than baked in. Defaults are the common North American game.
*/
@Serializable
data class EuchreOptions(
/** 24 cards (9–A) is standard; 32 (7–A) is a widespread longer variant. */
val deckSize: Int = 24,
/** Dealer must name trump rather than pass the hand out. Also called screw the dealer. */
val stickTheDealer: Boolean = true,
/** Allow a maker to play the hand without their partner for a bigger score. */
val allowGoingAlone: Boolean = true,
/** First team to this many points wins. 10 is standard; 11 and 15 are common. */
val pointsToWin: Int = 10,
) {
init {
require(deckSize == 24 || deckSize == 32) { "Euchre deck must be 24 or 32 cards" }
require(pointsToWin > 0) { "pointsToWin must be positive" }
}
}
@Serializable
enum class EuchrePhase {
/** Players may order up the turned card, in turn, starting left of the dealer. */
BID_ROUND_1,
/** The turn card is down; players may name any other suit. */
BID_ROUND_2,
/** The dealer has picked up the turn card and must discard one. */
DEALER_DISCARD,
PLAYING,
/** Five tricks played and scored; waiting to deal the next hand. */
HAND_OVER,
GAME_OVER,
}
@Serializable
data class PlayedCard(val seat: Int, val card: Card)
@Serializable
data class EuchreState(
val options: EuchreOptions,
val seed: Long,
val handNumber: Int,
val dealer: Int,
val phase: EuchrePhase,
/** Per-seat hands. Redacted to empty for seats other than the viewer. */
val hands: List<List<Card>>,
/** Card counts survive redaction so the table can still be drawn. */
val handCounts: List<Int>,
val upCard: Card?,
/** The turned-down card in round two; its suit may not be named. */
val turnedDown: Card?,
val trump: Suit?,
val maker: Int?,
val aloneSeat: Int?,
val turn: Int,
val passes: Int,
val trick: List<PlayedCard>,
val leader: Int,
val tricksWon: List<Int>,
/** Two entries: team 0 is seats 0 and 2, team 1 is seats 1 and 3. */
val scores: List<Int>,
val lastTrickWinner: Int?,
val log: List<String>,
) {
val sittingOut: Int? get() = aloneSeat?.let { partnerOf(it) }
fun isActive(seat: Int): Boolean = seat != sittingOut
val activeSeatCount: Int get() = if (aloneSeat != null) 3 else 4
}
@Serializable
sealed interface EuchreMove
@Serializable
@SerialName("pass")
data object Pass : EuchreMove
@Serializable
@SerialName("order_up")
data class OrderUp(val alone: Boolean) : EuchreMove
@Serializable
@SerialName("call_trump")
data class CallTrump(val suit: Suit, val alone: Boolean) : EuchreMove
@Serializable
@SerialName("discard")
data class Discard(val card: Card) : EuchreMove
@Serializable
@SerialName("play")
data class PlayCard(val card: Card) : EuchreMove
// ---------------------------------------------------------------------------
// Seating and card ranking
// ---------------------------------------------------------------------------
fun partnerOf(seat: Int): Int = (seat + 2) % 4
fun teamOf(seat: Int): Int = seat % 2
/** The jack of trump: the highest card in the game. */
fun isRightBower(card: Card, trump: Suit): Boolean =
card.rank == Rank.JACK && card.suit == trump
/**
* The jack of the same colour as trump. It counts as a trump card and outranks
* every trump except the right bower — the rule that catches every new player.
*/
fun isLeftBower(card: Card, trump: Suit): Boolean =
card.rank == Rank.JACK && card.suit == trump.sameColour()
/** The suit a card counts as, which is trump for the left bower. */
fun effectiveSuit(card: Card, trump: Suit?): Suit =
if (trump != null && isLeftBower(card, trump)) trump else card.suit
fun isTrumpCard(card: Card, trump: Suit?): Boolean =
trump != null && effectiveSuit(card, trump) == trump
/**
* Ranks a card within a trick. Higher wins. Cards that neither follow the led
* suit nor trump score zero and can never take the trick.
*/
fun trickStrength(card: Card, trump: Suit, ledSuit: Suit): Int = when {
isRightBower(card, trump) -> 1000
isLeftBower(card, trump) -> 900
isTrumpCard(card, trump) -> 800 + card.rank.order
effectiveSuit(card, trump) == ledSuit -> 100 + card.rank.order
else -> 0
}
/** Ranks a card in isolation, for AI hand evaluation. */
fun trumpStrength(card: Card, trump: Suit): Int = when {
isRightBower(card, trump) -> 1000
isLeftBower(card, trump) -> 900
isTrumpCard(card, trump) -> 800 + card.rank.order
else -> card.rank.order
}
@@ -0,0 +1,377 @@
package org.prolibertate.games.game.euchre
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Decks
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.cards.shuffledWith
import org.prolibertate.games.game.engine.GameRules
import org.prolibertate.games.game.engine.TableConfig
import kotlin.random.Random
/**
* Euchre as a pure state machine. See RULES-euchre.md for the ruleset and the
* variants exposed through [EuchreOptions].
*/
object EuchreRules : GameRules<EuchreState, EuchreMove> {
override val gameId: String = GameCatalog.EUCHRE
private const val CARDS_PER_HAND = 5
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
// -----------------------------------------------------------------------
// Setup
// -----------------------------------------------------------------------
override fun initialState(config: TableConfig): EuchreState {
require(config.seats.size == 4) { "Euchre is a four-handed game" }
val options = json.decodeFromString<EuchreOptions>(config.optionsJson)
return deal(
options = options,
seed = config.seed,
handNumber = 0,
dealer = 0,
scores = listOf(0, 0),
log = listOf("Hand 1 — dealer is seat 0."),
)
}
/** Deals a fresh hand and opens the first round of bidding. */
private fun deal(
options: EuchreOptions,
seed: Long,
handNumber: Int,
dealer: Int,
scores: List<Int>,
log: List<String>,
): EuchreState {
// Derived per hand so a given (seed, handNumber) always reproduces the
// same deal — handy for tests and for replaying a networked game.
val random = Random(seed + handNumber * 7919L)
val deck = Decks.euchre(options.deckSize).shuffledWith(random)
val hands = (0 until 4).map { seat ->
deck.subList(seat * CARDS_PER_HAND, (seat + 1) * CARDS_PER_HAND).toList()
}
val upCard = deck[4 * CARDS_PER_HAND]
return EuchreState(
options = options,
seed = seed,
handNumber = handNumber,
dealer = dealer,
phase = EuchrePhase.BID_ROUND_1,
hands = hands,
handCounts = hands.map { it.size },
upCard = upCard,
turnedDown = null,
trump = null,
maker = null,
aloneSeat = null,
turn = (dealer + 1) % 4,
passes = 0,
trick = emptyList(),
leader = (dealer + 1) % 4,
tricksWon = listOf(0, 0, 0, 0),
scores = scores,
lastTrickWinner = null,
log = log,
)
}
/**
* Advances from [EuchrePhase.HAND_OVER] into the next deal. Driven by the
* session controller once the finished hand has been on screen long enough
* to read, rather than by a player move.
*/
fun nextHand(state: EuchreState): EuchreState {
check(state.phase == EuchrePhase.HAND_OVER) { "Hand is not over" }
val dealer = (state.dealer + 1) % 4
return deal(
options = state.options,
seed = state.seed,
handNumber = state.handNumber + 1,
dealer = dealer,
scores = state.scores,
log = state.log + "Hand ${state.handNumber + 2} — dealer is seat $dealer.",
)
}
// -----------------------------------------------------------------------
// Turn order
// -----------------------------------------------------------------------
private fun nextActive(state: EuchreState, from: Int): Int {
var seat = (from + 1) % 4
while (seat == state.sittingOut) seat = (seat + 1) % 4
return seat
}
override fun currentSeat(state: EuchreState): Int? = when (state.phase) {
EuchrePhase.BID_ROUND_1,
EuchrePhase.BID_ROUND_2,
EuchrePhase.DEALER_DISCARD,
EuchrePhase.PLAYING,
-> state.turn
EuchrePhase.HAND_OVER, EuchrePhase.GAME_OVER -> null
}
// -----------------------------------------------------------------------
// Legal moves
// -----------------------------------------------------------------------
override fun legalMoves(state: EuchreState, seat: Int): List<EuchreMove> {
if (currentSeat(state) != seat) return emptyList()
return when (state.phase) {
EuchrePhase.BID_ROUND_1 -> buildList {
add(OrderUp(alone = false))
if (state.options.allowGoingAlone) add(OrderUp(alone = true))
add(Pass)
}
EuchrePhase.BID_ROUND_2 -> buildList {
val barred = state.turnedDown?.suit
for (suit in Suit.entries) {
if (suit == barred) continue
add(CallTrump(suit, alone = false))
if (state.options.allowGoingAlone) add(CallTrump(suit, alone = true))
}
// Stick the dealer: with three passes already in, the dealer has
// to name something rather than throw the hand in.
val dealerIsStuck =
state.options.stickTheDealer && seat == state.dealer && state.passes == 3
if (!dealerIsStuck) add(Pass)
}
EuchrePhase.DEALER_DISCARD -> state.hands[state.dealer].map { Discard(it) }
EuchrePhase.PLAYING -> playableCards(state, seat).map { PlayCard(it) }
EuchrePhase.HAND_OVER, EuchrePhase.GAME_OVER -> emptyList()
}
}
/** Follow the led suit if you can; otherwise anything goes. */
fun playableCards(state: EuchreState, seat: Int): List<Card> {
val hand = state.hands[seat]
val led = state.trick.firstOrNull() ?: return hand
val ledSuit = effectiveSuit(led.card, state.trump)
val following = hand.filter { effectiveSuit(it, state.trump) == ledSuit }
return following.ifEmpty { hand }
}
// -----------------------------------------------------------------------
// Move application
// -----------------------------------------------------------------------
override fun applyMove(state: EuchreState, seat: Int, move: EuchreMove): EuchreState {
require(currentSeat(state) == seat) { "Not seat $seat's turn" }
require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" }
return when (move) {
is Pass -> applyPass(state, seat)
is OrderUp -> applyOrderUp(state, seat, move.alone)
is CallTrump -> applyCallTrump(state, seat, move.suit, move.alone)
is Discard -> applyDiscard(state, move.card)
is PlayCard -> applyPlay(state, seat, move.card)
}.normalised()
}
private fun EuchreState.normalised(): EuchreState = copy(handCounts = hands.map { it.size })
private fun applyPass(state: EuchreState, seat: Int): EuchreState {
val passes = state.passes + 1
val log = state.log + "Seat $seat passes."
return when (state.phase) {
EuchrePhase.BID_ROUND_1 -> if (passes >= 4) {
state.copy(
phase = EuchrePhase.BID_ROUND_2,
turnedDown = state.upCard,
upCard = null,
turn = (state.dealer + 1) % 4,
passes = 0,
log = log + "${state.upCard?.label} turned down.",
)
} else {
state.copy(turn = (seat + 1) % 4, passes = passes, log = log)
}
EuchrePhase.BID_ROUND_2 -> if (passes >= 4) {
// Nobody wanted it and the dealer wasn't stuck: throw the hand in.
deal(
options = state.options,
seed = state.seed,
handNumber = state.handNumber + 1,
dealer = (state.dealer + 1) % 4,
scores = state.scores,
log = log + "Hand thrown in; redealing.",
)
} else {
state.copy(turn = (seat + 1) % 4, passes = passes, log = log)
}
else -> error("Pass is not legal in ${state.phase}")
}
}
private fun applyOrderUp(state: EuchreState, seat: Int, alone: Boolean): EuchreState {
val upCard = requireNotNull(state.upCard) { "No turn card to order up" }
// The dealer takes the turn card into hand and will discard one back.
val hands = state.hands.toMutableList()
hands[state.dealer] = hands[state.dealer] + upCard
return state.copy(
phase = EuchrePhase.DEALER_DISCARD,
hands = hands,
trump = upCard.suit,
maker = seat,
aloneSeat = if (alone) seat else null,
upCard = null,
turn = state.dealer,
log = state.log + buildString {
append("Seat $seat orders up ${upCard.suit.symbol}")
if (alone) append(" and goes alone")
append(".")
},
)
}
private fun applyCallTrump(
state: EuchreState,
seat: Int,
suit: Suit,
alone: Boolean,
): EuchreState {
val withTrump = state.copy(
trump = suit,
maker = seat,
aloneSeat = if (alone) seat else null,
log = state.log + buildString {
append("Seat $seat names ${suit.symbol}")
if (alone) append(" and goes alone")
append(".")
},
)
return beginPlay(withTrump)
}
private fun applyDiscard(state: EuchreState, card: Card): EuchreState {
val hands = state.hands.toMutableList()
hands[state.dealer] = hands[state.dealer] - card
return beginPlay(state.copy(hands = hands, log = state.log + "Dealer discards."))
}
/** Trump is settled: clear any lone hand's partner and lead to the first trick. */
private fun beginPlay(state: EuchreState): EuchreState {
val sittingOut = state.sittingOut
val hands = state.hands.toMutableList()
if (sittingOut != null) hands[sittingOut] = emptyList()
val leader = nextActive(state, state.dealer)
return state.copy(
phase = EuchrePhase.PLAYING,
hands = hands,
leader = leader,
turn = leader,
trick = emptyList(),
passes = 0,
)
}
private fun applyPlay(state: EuchreState, seat: Int, card: Card): EuchreState {
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)
if (trick.size < state.activeSeatCount) {
return played.copy(turn = nextActive(state, seat))
}
// Trick complete: resolve it.
val trump = requireNotNull(state.trump)
val ledSuit = effectiveSuit(trick.first().card, trump)
val winner = trick.maxBy { trickStrength(it.card, trump, ledSuit) }.seat
val tricksWon = state.tricksWon.toMutableList()
tricksWon[winner] = tricksWon[winner] + 1
val afterTrick = played.copy(
trick = emptyList(),
tricksWon = tricksWon,
leader = winner,
turn = winner,
lastTrickWinner = winner,
log = state.log + "Seat $winner takes the trick.",
)
val handFinished = afterTrick.hands.all { it.isEmpty() }
return if (handFinished) scoreHand(afterTrick) else afterTrick
}
// -----------------------------------------------------------------------
// Scoring
// -----------------------------------------------------------------------
private fun scoreHand(state: EuchreState): EuchreState {
val maker = requireNotNull(state.maker)
val makerTeam = teamOf(maker)
val makerTricks = state.tricksWon.filterIndexed { seat, _ -> teamOf(seat) == makerTeam }.sum()
val scores = state.scores.toMutableList()
val note: String
if (makerTricks >= 3) {
val points = when {
makerTricks == 5 && state.aloneSeat != null -> 4
makerTricks == 5 -> 2
else -> 1
}
scores[makerTeam] = scores[makerTeam] + points
note = "Team $makerTeam took $makerTricks tricks for $points point(s)."
} else {
// Euchred: the defenders score two regardless of how it fell apart.
val defenders = 1 - makerTeam
scores[defenders] = scores[defenders] + 2
note = "Team $makerTeam is euchred — team $defenders scores 2."
}
val winner = scores.indexOfFirst { it >= state.options.pointsToWin }
return state.copy(
phase = if (winner >= 0) EuchrePhase.GAME_OVER else EuchrePhase.HAND_OVER,
scores = scores,
log = state.log + note + if (winner >= 0) listOf("Team $winner wins.") else emptyList(),
)
}
override fun isFinished(state: EuchreState): Boolean = state.phase == EuchrePhase.GAME_OVER
override fun summary(state: EuchreState): String = when (state.phase) {
EuchrePhase.GAME_OVER -> {
val winner = state.scores.indexOfFirst { it >= state.options.pointsToWin }
"Team $winner wins ${state.scores[0]}–${state.scores[1]}"
}
else -> "We ${state.scores[0]} — They ${state.scores[1]}"
}
// -----------------------------------------------------------------------
// Redaction and wire format
// -----------------------------------------------------------------------
override fun viewFor(state: EuchreState, seat: Int): EuchreState = state.copy(
hands = state.hands.mapIndexed { index, hand -> if (index == seat) hand else emptyList() },
handCounts = state.handCounts,
)
override fun encodeState(state: EuchreState): String = json.encodeToString(state)
override fun decodeState(json: String): EuchreState =
this.json.decodeFromString(json)
override fun encodeMove(move: EuchreMove): String = json.encodeToString(move)
override fun decodeMove(json: String): EuchreMove =
this.json.decodeFromString(json)
}
@@ -0,0 +1,122 @@
package org.prolibertate.games.game.sequence
import org.prolibertate.games.game.engine.GameAi
/**
* A heuristic Sequence opponent.
*
* It scores every legal placement by how much it advances one of its own runs
* and how badly it spoils an opponent's, holds its jacks back until they are
* worth spending, and never looks at anyone's hand.
*/
class SequenceAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
GameAi<SequenceState, SequenceMove> {
enum class Difficulty(val blockWeight: Int, val jackPatience: Int) {
/** Builds its own runs and mostly ignores yours. */
EASY(blockWeight = 1, jackPatience = 0),
NORMAL(blockWeight = 3, jackPatience = 3),
/** Blocks aggressively and saves jacks for real threats. */
CAUTIOUS(blockWeight = 5, jackPatience = 4),
}
override fun chooseMove(
state: SequenceState,
seat: Int,
legal: List<SequenceMove>,
): SequenceMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
val team = state.teamOf(seat)
// Clearing a dead card is free value when nothing better is on offer.
val exchange = legal.filterIsInstance<ExchangeDeadCard>().firstOrNull()
if (exchange != null && legal.none { it is PlaceChip }) return exchange
val best = legal
.filterIsInstance<PlaceChip>()
.maxByOrNull { scorePlacement(state, it, team) }
val removal = legal
.filterIsInstance<RemoveChip>()
.maxByOrNull { threatAt(state, it.cell, state.chips[it.cell]) }
// Spend a one-eyed jack only on a genuinely dangerous chip.
if (removal != null) {
val threat = threatAt(state, removal.cell, state.chips[removal.cell])
if (threat >= difficulty.jackPatience) return removal
}
return best ?: removal ?: exchange ?: legal.first()
}
/**
* Rates placing a chip: how long a run it joins for us, plus how much of an
* opponent's run it interrupts. Wild jacks are discounted so they are not
* burned on an ordinary square.
*/
private fun scorePlacement(state: SequenceState, move: PlaceChip, team: Int): Int {
val ours = runPotential(state, move.cell, team)
val theirs = (0 until state.options.teamCount)
.filter { it != team }
.maxOfOrNull { runPotential(state, move.cell, it) }
?: 0
var score = ours * 4 + theirs * difficulty.blockWeight
// Central squares sit on more possible lines than edge squares.
val row = SequenceBoard.rowOf(move.cell)
val col = SequenceBoard.colOf(move.cell)
score += 4 - (kotlin.math.abs(row - 4) + kotlin.math.abs(col - 4)) / 2
if (isTwoEyedJack(move.card)) score -= 12
return score
}
/**
* The best run this team could still build through [cell]: the highest
* count of friendly-or-free squares in any five-window containing it, once
* windows blocked by an opponent are discarded.
*/
private fun runPotential(state: SequenceState, cell: Int, team: Int): Int {
val row = SequenceBoard.rowOf(cell)
val col = SequenceBoard.colOf(cell)
var best = 0
for ((dr, dc) in DIRECTIONS) {
for (offset in -(RUN_LENGTH - 1)..0) {
var count = 0
var blocked = false
for (step in 0 until RUN_LENGTH) {
val r = row + (offset + step) * dr
val c = col + (offset + step) * dc
if (r !in 0 until BOARD_SIZE || c !in 0 until BOARD_SIZE) {
blocked = true
break
}
val target = SequenceBoard.cellAt(r, c)
val occupant = state.chips[target]
when {
SequenceBoard.isCorner(target) -> count++
target == cell -> count++
occupant == team -> count++
occupant == NO_TEAM -> Unit
else -> {
blocked = true
}
}
if (blocked) break
}
if (!blocked && count > best) best = count
}
}
return best
}
/** How close [team] is to completing a run through [cell]. */
private fun threatAt(state: SequenceState, cell: Int, team: Int): Int =
if (team == NO_TEAM) 0 else runPotential(state, cell, team)
private companion object {
val DIRECTIONS = listOf(1 to 0, 0 to 1, 1 to 1, 1 to -1)
}
}
@@ -0,0 +1,144 @@
package org.prolibertate.games.game.sequence
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
const val BOARD_SIZE = 10
const val BOARD_CELLS = BOARD_SIZE * BOARD_SIZE
const val RUN_LENGTH = 5
/** Sentinel stored in [SequenceState.chips] for an unoccupied square. */
const val NO_TEAM = -1
/**
* The printed board.
*
* IMPORTANT: this is a generated layout, not a reproduction of the commercial
* Sequence board. It satisfies every structural property the rules depend on —
* each of the 48 non-jack cards appears exactly twice, the four corners are
* free squares, and no card sits next to its own twin — so play is correct, but
* square-for-square it will not match a physical board. Replace [LAYOUT] with
* the real arrangement to match one; nothing else needs to change.
*/
object SequenceBoard {
private val LAYOUT: List<String> = listOf(
"FREE 10C 6D 4C 7C 8H 9S 10C 10H FREE",
" 3H 8S 2H 9C AH 10S 4S 5H QH 4H",
" 3S AS KD KC AC KS 3D 10D 6D KH",
" 6H 3D 3C QC 7D 8H 9D 5S 4H 6C",
" AD 5D 8S 9S 4S 2D 10H QD 5D QC",
" 7H 9D QH 2C 4C AS AD 8C 6C QS",
" 2S 6S 8D KH 7C 7S 4D 7S 3H 3S",
" 10S 4D 7H AC 2H 2C 3C 6S 2S 7D",
" 8C 10D KD 5H 9H 5C QS 8D 9H 9C",
"FREE 6H KS 2D AH 5S QD KC 5C FREE",
)
/** Card printed on each square, or null for the four free corners. */
val cells: List<Card?> = LAYOUT.flatMap { row ->
row.trim().split(Regex("\\s+")).map { token ->
if (token == "FREE") null else Card.parse(token)
}
}.also { parsed ->
require(parsed.size == BOARD_CELLS) { "Board must have $BOARD_CELLS squares" }
}
/** Squares showing a given card. Every non-jack card occupies exactly two. */
val squaresByCard: Map<Card, List<Int>> = cells
.withIndex()
.filter { it.value != null }
.groupBy({ it.value!! }, { it.index })
fun isCorner(cell: Int): Boolean = cells[cell] == null
fun rowOf(cell: Int): Int = cell / BOARD_SIZE
fun colOf(cell: Int): Int = cell % BOARD_SIZE
fun cellAt(row: Int, col: Int): Int = row * BOARD_SIZE + col
}
/** Jacks do not appear on the board; they are the two wild actions instead. */
fun isTwoEyedJack(card: Card): Boolean =
card.rank == Rank.JACK && (card.suit == Suit.DIAMONDS || card.suit == Suit.CLUBS)
fun isOneEyedJack(card: Card): Boolean =
card.rank == Rank.JACK && (card.suit == Suit.HEARTS || card.suit == Suit.SPADES)
@Serializable
data class SequenceOptions(
/** Two teams need two sequences; three teams need one. */
val teamCount: Int = 2,
val playersPerTeam: Int = 1,
val sequencesToWin: Int = 2,
/** Allow swapping a card whose squares are both taken. */
val deadCardExchange: Boolean = true,
) {
init {
require(teamCount in 2..3) { "Sequence supports two or three teams" }
require(playersPerTeam >= 1) { "Each team needs at least one player" }
require(sequencesToWin >= 1) { "sequencesToWin must be positive" }
}
val playerCount: Int get() = teamCount * playersPerTeam
}
/**
* Cards dealt per player, which shrinks as the table grows. These are the
* standard counts; unusual player counts fall back to the nearest band.
*/
fun handSizeFor(players: Int): Int = when {
players <= 2 -> 7
players <= 4 -> 6
players <= 6 -> 5
players <= 9 -> 4
else -> 3
}
@Serializable
enum class SequencePhase { PLAYING, GAME_OVER }
@Serializable
data class SequenceState(
val options: SequenceOptions,
val seed: Long,
/** Team index per seat. */
val teams: List<Int>,
/** Occupying team per square, or [NO_TEAM]. */
val chips: List<Int>,
/** Squares belonging to a completed sequence: safe from one-eyed jacks. */
val locked: List<Boolean>,
val hands: List<List<Card>>,
val handCounts: List<Int>,
val drawPile: List<Card>,
val discardPile: List<Card>,
val turn: Int,
val sequencesByTeam: List<Int>,
val winner: Int?,
val phase: SequencePhase,
/** Set once per turn when a dead card has already been exchanged. */
val exchangedThisTurn: Boolean,
val lastPlacedCell: Int?,
val log: List<String>,
) {
val playerCount: Int get() = teams.size
fun teamOf(seat: Int): Int = teams[seat]
}
@Serializable
sealed interface SequenceMove
@Serializable
@SerialName("place")
data class PlaceChip(val card: Card, val cell: Int) : SequenceMove
@Serializable
@SerialName("remove")
data class RemoveChip(val card: Card, val cell: Int) : SequenceMove
@Serializable
@SerialName("exchange")
data class ExchangeDeadCard(val card: Card) : SequenceMove
@@ -0,0 +1,302 @@
package org.prolibertate.games.game.sequence
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Decks
import org.prolibertate.games.game.cards.shuffledWith
import org.prolibertate.games.game.engine.GameRules
import org.prolibertate.games.game.engine.TableConfig
import kotlin.random.Random
/**
* Sequence as a pure state machine. See RULES-sequence.md for the ruleset and
* the caveat about the board layout.
*/
object SequenceRules : GameRules<SequenceState, SequenceMove> {
override val gameId: String = GameCatalog.SEQUENCE
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
/** The four straight lines a run of five can follow. */
private val DIRECTIONS = listOf(
1 to 0, // horizontal
0 to 1, // vertical
1 to 1, // diagonal down-right
1 to -1, // diagonal down-left
)
override fun initialState(config: TableConfig): SequenceState {
val options = json.decodeFromString<SequenceOptions>(config.optionsJson)
require(config.seats.size == options.playerCount) {
"Seat count ${config.seats.size} does not match ${options.playerCount} players"
}
val random = Random(config.seed)
var deck = Decks.double52().shuffledWith(random)
val handSize = handSizeFor(options.playerCount)
val hands = mutableListOf<List<Card>>()
for (seat in 0 until options.playerCount) {
hands += deck.take(handSize)
deck = deck.drop(handSize)
}
// Seats alternate around the table so partners are never adjacent.
val teams = (0 until options.playerCount).map { it % options.teamCount }
return SequenceState(
options = options,
seed = config.seed,
teams = teams,
chips = List(BOARD_CELLS) { NO_TEAM },
locked = List(BOARD_CELLS) { false },
hands = hands,
handCounts = hands.map { it.size },
drawPile = deck,
discardPile = emptyList(),
turn = 0,
sequencesByTeam = List(options.teamCount) { 0 },
winner = null,
phase = SequencePhase.PLAYING,
exchangedThisTurn = false,
lastPlacedCell = null,
log = listOf("Sequence — ${options.teamCount} teams, first to ${options.sequencesToWin}."),
)
}
override fun currentSeat(state: SequenceState): Int? =
if (state.phase == SequencePhase.PLAYING) state.turn else null
// -----------------------------------------------------------------------
// Legal moves
// -----------------------------------------------------------------------
override fun legalMoves(state: SequenceState, seat: Int): List<SequenceMove> {
if (currentSeat(state) != seat) return emptyList()
val team = state.teamOf(seat)
val moves = mutableListOf<SequenceMove>()
for (card in state.hands[seat].distinct()) {
when {
isTwoEyedJack(card) ->
// Wild: any empty square on the board.
for (cell in 0 until BOARD_CELLS) {
if (!SequenceBoard.isCorner(cell) && state.chips[cell] == NO_TEAM) {
moves += PlaceChip(card, cell)
}
}
isOneEyedJack(card) ->
// Removes one opposing chip that is not part of a sequence.
for (cell in 0 until BOARD_CELLS) {
val occupant = state.chips[cell]
if (occupant != NO_TEAM && occupant != team && !state.locked[cell]) {
moves += RemoveChip(card, cell)
}
}
else -> {
val squares = SequenceBoard.squaresByCard[card].orEmpty()
val open = squares.filter { state.chips[it] == NO_TEAM }
if (open.isEmpty()) {
// Dead card: both printed squares are taken.
if (state.options.deadCardExchange && !state.exchangedThisTurn) {
moves += ExchangeDeadCard(card)
}
} else {
open.forEach { moves += PlaceChip(card, it) }
}
}
}
}
return moves
}
// -----------------------------------------------------------------------
// Move application
// -----------------------------------------------------------------------
override fun applyMove(state: SequenceState, seat: Int, move: SequenceMove): SequenceState {
require(currentSeat(state) == seat) { "Not seat $seat's turn" }
require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" }
return when (move) {
is PlaceChip -> applyPlace(state, seat, move)
is RemoveChip -> applyRemove(state, seat, move)
is ExchangeDeadCard -> applyExchange(state, seat, move)
}
}
private fun applyPlace(state: SequenceState, seat: Int, move: PlaceChip): SequenceState {
val team = state.teamOf(seat)
val chips = state.chips.toMutableList()
chips[move.cell] = team
val locked = state.locked.toMutableList()
val newSequences = claimSequences(chips, locked, move.cell, team)
val sequences = state.sequencesByTeam.toMutableList()
sequences[team] = sequences[team] + newSequences
val afterCard = discardAndDraw(state, seat, move.card)
val won = sequences[team] >= state.options.sequencesToWin
return afterCard.copy(
chips = chips,
locked = locked,
sequencesByTeam = sequences,
lastPlacedCell = move.cell,
winner = if (won) team else null,
phase = if (won) SequencePhase.GAME_OVER else SequencePhase.PLAYING,
log = afterCard.log + buildString {
append("Seat $seat plays ${move.card.label}")
if (newSequences > 0) append(" — sequence!")
append(".")
},
).advanceTurn(skip = won)
}
private fun applyRemove(state: SequenceState, seat: Int, move: RemoveChip): SequenceState {
val chips = state.chips.toMutableList()
chips[move.cell] = NO_TEAM
return discardAndDraw(state, seat, move.card)
.copy(
chips = chips,
log = state.log + "Seat $seat removes a chip with ${move.card.label}.",
)
.advanceTurn(skip = false)
}
/** Swapping a dead card costs the card but not the turn. */
private fun applyExchange(
state: SequenceState,
seat: Int,
move: ExchangeDeadCard,
): SequenceState = discardAndDraw(state, seat, move.card).copy(
exchangedThisTurn = true,
log = state.log + "Seat $seat swaps dead card ${move.card.label}.",
)
/** Moves the played card to the discard pile and replaces it from the deck. */
private fun discardAndDraw(
state: SequenceState,
seat: Int,
card: Card,
): SequenceState {
val hands = state.hands.toMutableList()
hands[seat] = hands[seat] - card
var draw = state.drawPile
var discard = state.discardPile
if (draw.isEmpty() && discard.isNotEmpty()) {
// Deck exhausted: the discards are shuffled back in and play
// continues. This happens before the card just played joins the
// pile, so a swapped dead card can never come straight back —
// which would otherwise let a player exchange the same card
// forever.
val random = Random(state.seed + state.log.size)
draw = discard.shuffledWith(random)
discard = emptyList()
}
discard = discard + card
if (draw.isNotEmpty()) {
hands[seat] = hands[seat] + draw.first()
draw = draw.drop(1)
}
return state.copy(
hands = hands,
handCounts = hands.map { it.size },
drawPile = draw,
discardPile = discard,
)
}
private fun SequenceState.advanceTurn(skip: Boolean): SequenceState =
if (skip) this
else copy(turn = (turn + 1) % playerCount, exchangedThisTurn = false)
// -----------------------------------------------------------------------
// Sequence detection
// -----------------------------------------------------------------------
/**
* Counts and locks any runs of five completed by the chip just played at
* [cell].
*
* Two sequences may share at most one chip, so a candidate run is only
* accepted when at most one of its squares is already locked. Corners are
* free squares that count for every team.
*/
private fun claimSequences(
chips: List<Int>,
locked: MutableList<Boolean>,
cell: Int,
team: Int,
): Int {
fun ownedBy(c: Int): Boolean = SequenceBoard.isCorner(c) || chips[c] == team
val row = SequenceBoard.rowOf(cell)
val col = SequenceBoard.colOf(cell)
var found = 0
for ((dr, dc) in DIRECTIONS) {
// Every window of five that includes the new chip.
for (offset in -(RUN_LENGTH - 1)..0) {
val window = (0 until RUN_LENGTH).map { step ->
val r = row + (offset + step) * dr
val c = col + (offset + step) * dc
if (r !in 0 until BOARD_SIZE || c !in 0 until BOARD_SIZE) return@map -1
SequenceBoard.cellAt(r, c)
}
if (window.any { it < 0 }) continue
if (!window.all { ownedBy(it) }) continue
// Free corners are shared by everyone and never count as the
// already-locked chip for the shared-chip limit.
val lockedInWindow = window.count { locked[it] && !SequenceBoard.isCorner(it) }
if (lockedInWindow > 1) continue
window.forEach { locked[it] = true }
found++
break // At most one new sequence per direction.
}
}
return found
}
// -----------------------------------------------------------------------
// Results, redaction and wire format
// -----------------------------------------------------------------------
override fun isFinished(state: SequenceState): Boolean =
state.phase == SequencePhase.GAME_OVER
override fun summary(state: SequenceState): String = when (val winner = state.winner) {
null -> state.sequencesByTeam
.mapIndexed { team, count -> "Team $team: $count" }
.joinToString(" ")
else -> "Team $winner wins"
}
override fun viewFor(state: SequenceState, seat: Int): SequenceState = state.copy(
hands = state.hands.mapIndexed { index, hand -> if (index == seat) hand else emptyList() },
handCounts = state.handCounts,
// The deck order is hidden information too.
drawPile = emptyList(),
)
override fun encodeState(state: SequenceState): String = json.encodeToString(state)
override fun decodeState(json: String): SequenceState = this.json.decodeFromString(json)
override fun encodeMove(move: SequenceMove): String = json.encodeToString(move)
override fun decodeMove(json: String): SequenceMove = this.json.decodeFromString(json)
}
@@ -0,0 +1,176 @@
package org.prolibertate.games.net
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothServerSocket
import android.bluetooth.BluetoothSocket
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.UUID
/**
* Bluetooth play over Classic RFCOMM.
*
* RFCOMM rather than BLE: this is a sustained, ordered, two-way stream of small
* messages, which is exactly what a serial profile is for. BLE would mean
* chunking everything into 20-byte GATT writes for no benefit.
*
* Discovery is limited to *paired* devices. Pair the two devices once in
* Android's Bluetooth settings and they will see each other here; scanning for
* unpaired devices needs a foreground scan plus location permission on older
* releases and is not worth the friction for a game lobby.
*/
class BluetoothTransport(private val context: Context) : Transport {
override val kind: TransportKind = TransportKind.BLUETOOTH
private val serviceUuid: UUID = UUID.fromString(RFCOMM_UUID)
private val adapter: BluetoothAdapter?
get() = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
private var serverSocket: BluetoothServerSocket? = null
override fun isAvailable(): Boolean = adapter?.isEnabled == true && hasConnectPermission()
/**
* Android 12 replaced the blanket BLUETOOTH permission with BLUETOOTH_CONNECT.
* Below 31 the manifest permission is granted at install time.
*/
fun hasConnectPermission(): Boolean =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
ContextCompat.checkSelfPermission(
context,
android.Manifest.permission.BLUETOOTH_CONNECT,
) == PackageManager.PERMISSION_GRANTED
} else {
true
}
// -----------------------------------------------------------------------
// Hosting
// -----------------------------------------------------------------------
@SuppressLint("MissingPermission")
override fun host(displayName: String, scope: CoroutineScope): Flow<Connection> = callbackFlow {
val bluetooth = adapter
if (bluetooth == null || !hasConnectPermission()) {
close()
return@callbackFlow
}
val server = runCatching {
bluetooth.listenUsingRfcommWithServiceRecord(SERVICE_NAME, serviceUuid)
}.getOrNull()
if (server == null) {
close()
return@callbackFlow
}
serverSocket = server
val acceptJob = scope.launch(Dispatchers.IO) {
while (isActive) {
val socket: BluetoothSocket = runCatching { server.accept() }.getOrNull() ?: break
val connection = StreamConnection(
peerId = runCatching { socket.remoteDevice?.address }.getOrNull() ?: "bt",
kind = TransportKind.BLUETOOTH,
input = socket.inputStream,
output = socket.outputStream,
scope = scope,
onClosed = { runCatching { socket.close() } },
)
trySend(connection)
}
}
awaitClose {
acceptJob.cancel()
runCatching { server.close() }
serverSocket = null
}
}
// -----------------------------------------------------------------------
// Discovery
// -----------------------------------------------------------------------
/**
* Paired devices are re-published on a slow poll: there is no callback for
* "a device became reachable", and a host that is not listening simply
* refuses the connection when joined.
*/
@SuppressLint("MissingPermission")
override fun discover(scope: CoroutineScope): Flow<List<DiscoveredHost>> = callbackFlow {
if (!hasConnectPermission()) {
trySend(emptyList())
close()
return@callbackFlow
}
val pollJob = scope.launch {
while (isActive) {
val bonded = runCatching { adapter?.bondedDevices.orEmpty() }.getOrDefault(emptySet())
trySend(
bonded.map { device ->
DiscoveredHost(
id = "bt:${device.address}",
name = runCatching { device.name }.getOrNull() ?: device.address,
kind = TransportKind.BLUETOOTH,
address = device.address,
)
}
)
delay(POLL_INTERVAL_MS)
}
}
awaitClose { pollJob.cancel() }
}
// -----------------------------------------------------------------------
// Joining
// -----------------------------------------------------------------------
@SuppressLint("MissingPermission")
override suspend fun join(host: DiscoveredHost, scope: CoroutineScope): Connection =
withContext(Dispatchers.IO) {
val bluetooth = requireNotNull(adapter) { "No Bluetooth adapter" }
require(hasConnectPermission()) { "Bluetooth permission not granted" }
val device = bluetooth.getRemoteDevice(host.address)
// Discovery is expensive and slows down a connect attempt sharply.
runCatching { bluetooth.cancelDiscovery() }
val socket = device.createRfcommSocketToServiceRecord(serviceUuid)
socket.connect()
StreamConnection(
peerId = host.id,
kind = TransportKind.BLUETOOTH,
input = socket.inputStream,
output = socket.outputStream,
scope = scope,
onClosed = { runCatching { socket.close() } },
)
}
override fun stop() {
runCatching { serverSocket?.close() }
serverSocket = null
}
private companion object {
const val POLL_INTERVAL_MS = 4_000L
}
}
@@ -0,0 +1,215 @@
package org.prolibertate.games.net
import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.InetSocketAddress
import java.net.ServerSocket
import java.net.Socket
import java.util.Collections
/**
* LAN play: mDNS service discovery to find the host, then a plain TCP socket
* per client.
*
* NSD is used rather than a UDP broadcast ping because it works across the
* Wi-Fi isolation settings most home routers ship with, and because Android
* gives us the resolver for free.
*/
class LanTransport(private val context: Context) : Transport {
override val kind: TransportKind = TransportKind.LAN
private val nsdManager: NsdManager
get() = context.getSystemService(Context.NSD_SERVICE) as NsdManager
private var serverSocket: ServerSocket? = null
private var registrationListener: NsdManager.RegistrationListener? = null
private var discoveryListener: NsdManager.DiscoveryListener? = null
override fun isAvailable(): Boolean = true
// -----------------------------------------------------------------------
// Hosting
// -----------------------------------------------------------------------
override fun host(displayName: String, scope: CoroutineScope): Flow<Connection> = callbackFlow {
// Port 0 lets the OS pick a free port, which is then advertised.
val server = ServerSocket(0)
serverSocket = server
val serviceInfo = NsdServiceInfo().apply {
serviceName = "$SERVICE_NAME @ $displayName"
serviceType = SERVICE_TYPE
port = server.localPort
}
val listener = object : NsdManager.RegistrationListener {
override fun onServiceRegistered(info: NsdServiceInfo) = Unit
override fun onRegistrationFailed(info: NsdServiceInfo, errorCode: Int) = Unit
override fun onServiceUnregistered(info: NsdServiceInfo) = Unit
override fun onUnregistrationFailed(info: NsdServiceInfo, errorCode: Int) = Unit
}
registrationListener = listener
runCatching {
nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, listener)
}
val acceptJob = scope.launch(Dispatchers.IO) {
while (isActive && !server.isClosed) {
val socket = runCatching { server.accept() }.getOrNull() ?: break
socket.tcpNoDelay = true
val connection = StreamConnection(
peerId = socket.inetAddress?.hostAddress ?: "unknown",
kind = TransportKind.LAN,
input = socket.getInputStream(),
output = socket.getOutputStream(),
scope = scope,
onClosed = { runCatching { socket.close() } },
)
trySend(connection)
}
}
awaitClose {
acceptJob.cancel()
stopAdvertising()
runCatching { server.close() }
}
}
// -----------------------------------------------------------------------
// Discovery
// -----------------------------------------------------------------------
override fun discover(scope: CoroutineScope): Flow<List<DiscoveredHost>> = callbackFlow {
val found = Collections.synchronizedMap(linkedMapOf<String, DiscoveredHost>())
fun publish() {
trySend(synchronized(found) { found.values.toList() })
}
// Resolutions are queued one at a time: NsdManager rejects concurrent
// resolve calls with FAILURE_ALREADY_ACTIVE on many devices.
val pending = ArrayDeque<NsdServiceInfo>()
var resolving = false
fun resolveNext() {
if (resolving) return
val next = synchronized(pending) { pending.removeFirstOrNull() } ?: return
resolving = true
val resolveListener = object : NsdManager.ResolveListener {
override fun onResolveFailed(info: NsdServiceInfo, errorCode: Int) {
resolving = false
resolveNext()
}
override fun onServiceResolved(info: NsdServiceInfo) {
val address = info.host?.hostAddress
if (address != null) {
val host = DiscoveredHost(
id = "lan:$address:${info.port}",
name = info.serviceName.substringAfter("@ ").trim()
.ifBlank { info.serviceName },
kind = TransportKind.LAN,
address = address,
port = info.port,
)
synchronized(found) { found[host.id] = host }
publish()
}
resolving = false
resolveNext()
}
}
@Suppress("DEPRECATION")
runCatching { nsdManager.resolveService(next, resolveListener) }
.onFailure {
resolving = false
resolveNext()
}
}
val listener = object : NsdManager.DiscoveryListener {
override fun onDiscoveryStarted(serviceType: String) = Unit
override fun onServiceFound(info: NsdServiceInfo) {
if (info.serviceType?.contains("plgames") != true) return
synchronized(pending) { pending.addLast(info) }
resolveNext()
}
override fun onServiceLost(info: NsdServiceInfo) {
synchronized(found) {
val key = found.entries.firstOrNull { it.value.name == info.serviceName }?.key
if (key != null) found.remove(key)
}
publish()
}
override fun onDiscoveryStopped(serviceType: String) = Unit
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
close()
}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) = Unit
}
discoveryListener = listener
publish()
runCatching {
nsdManager.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener)
}.onFailure { close() }
awaitClose { stopDiscovery() }
}
// -----------------------------------------------------------------------
// Joining
// -----------------------------------------------------------------------
override suspend fun join(host: DiscoveredHost, scope: CoroutineScope): Connection =
withContext(Dispatchers.IO) {
val socket = Socket()
socket.connect(InetSocketAddress(host.address, host.port), CONNECT_TIMEOUT_MS)
socket.tcpNoDelay = true
StreamConnection(
peerId = host.id,
kind = TransportKind.LAN,
input = socket.getInputStream(),
output = socket.getOutputStream(),
scope = scope,
onClosed = { runCatching { socket.close() } },
)
}
// -----------------------------------------------------------------------
private fun stopAdvertising() {
registrationListener?.let { runCatching { nsdManager.unregisterService(it) } }
registrationListener = null
}
private fun stopDiscovery() {
discoveryListener?.let { runCatching { nsdManager.stopServiceDiscovery(it) } }
discoveryListener = null
}
override fun stop() {
stopAdvertising()
stopDiscovery()
runCatching { serverSocket?.close() }
serverSocket = null
}
private companion object {
const val CONNECT_TIMEOUT_MS = 8_000
}
}
@@ -0,0 +1,262 @@
package org.prolibertate.games.net
import android.content.Context
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
/**
* The pre-game lobby.
*
* The host advertises on every available transport at once, so a table can mix
* players arriving over Wi-Fi with players arriving over Bluetooth — from the
* seat list's point of view a peer is a peer. Seats start filled with AI and
* are handed over to people as they join, which is what makes "AI, humans, or
* any combination" fall out for free: whatever is still AI when the host
* starts, stays AI.
*/
class LobbyController(
context: Context,
private val scope: CoroutineScope,
) {
data class State(
val hosting: Boolean = false,
val joining: Boolean = false,
val connected: Boolean = false,
val gameId: String? = null,
val optionsJson: String = "{}",
val hostName: String = "",
val seats: List<PlayerSlot> = emptyList(),
val discovered: List<DiscoveredHost> = emptyList(),
val message: String? = null,
val started: TableConfig? = null,
)
private val lan = LanTransport(context)
private val bluetooth = BluetoothTransport(context)
val transports: List<Transport> = listOf(lan, bluetooth)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
/** Peer id -> live connection, for every seated remote player. */
private val connections = linkedMapOf<String, Connection>()
private var clientLink: Connection? = null
private val jobs = mutableListOf<Job>()
val hostConnections: List<Connection> get() = connections.values.toList()
val clientConnection: Connection? get() = clientLink
fun availableTransports(): List<Transport> = transports.filter { it.isAvailable() }
// -----------------------------------------------------------------------
// Hosting
// -----------------------------------------------------------------------
fun startHosting(
gameId: String,
optionsJson: String,
hostName: String,
seatCount: Int,
teamOf: (Int) -> Int,
) {
stop()
val seats = (0 until seatCount).map { seat ->
PlayerSlot(
seat = seat,
name = if (seat == 0) hostName else "Computer $seat",
kind = if (seat == 0) PlayerKind.HUMAN_LOCAL else PlayerKind.AI,
team = teamOf(seat),
)
}
_state.value = State(
hosting = true,
gameId = gameId,
optionsJson = optionsJson,
hostName = hostName,
seats = seats,
message = "Waiting for players…",
)
for (transport in availableTransports()) {
jobs += scope.launch {
transport.host(hostName, scope).collect { connection ->
onClientConnected(connection)
}
}
}
}
private fun onClientConnected(connection: Connection) {
jobs += scope.launch {
connection.incoming.collect { message ->
if (message !is Hello) return@collect
if (message.protocol != PROTOCOL_VERSION) {
connection.send(
Welcome(
hostName = _state.value.hostName,
gameId = _state.value.gameId.orEmpty(),
accepted = false,
reason = "Different app version — update both devices.",
)
)
connection.close()
return@collect
}
seatPlayer(connection, message)
}
}
}
/** Gives an arriving player the first seat still held by the computer. */
private suspend fun seatPlayer(connection: Connection, hello: Hello) {
val current = _state.value
val seats = current.seats.toMutableList()
val index = seats.indexOfFirst { it.kind == PlayerKind.AI }
if (index < 0) {
connection.send(
Welcome(current.hostName, current.gameId.orEmpty(), accepted = false, reason = "The table is full.")
)
connection.close()
return
}
seats[index] = seats[index].copy(
name = hello.displayName,
kind = PlayerKind.HUMAN_REMOTE,
peerId = connection.peerId,
)
connections[connection.peerId] = connection
_state.value = current.copy(
seats = seats,
message = "${hello.displayName} joined over ${connection.kind.label}.",
)
connection.send(Welcome(current.hostName, current.gameId.orEmpty(), accepted = true))
broadcastLobby()
}
private suspend fun broadcastLobby() {
val current = _state.value
val update = LobbyUpdate(
gameId = current.gameId.orEmpty(),
seats = current.seats,
optionsJson = current.optionsJson,
hostName = current.hostName,
)
connections.values.forEach { it.send(update) }
}
/** Host: lock the table in and tell everyone to build the same match. */
fun startMatch(seed: Long): TableConfig {
val current = _state.value
val config = TableConfig(
gameId = current.gameId.orEmpty(),
seats = current.seats,
optionsJson = current.optionsJson,
seed = seed,
)
scope.launch { connections.values.forEach { it.send(StartGame(config)) } }
_state.value = current.copy(started = config)
return config
}
/** Replaces a seat's occupant — used by the host to swap a person for AI. */
fun setSeatKind(seat: Int, kind: PlayerKind, name: String? = null) {
val current = _state.value
val seats = current.seats.toMutableList()
if (seat !in seats.indices) return
seats[seat] = seats[seat].copy(
kind = kind,
name = name ?: seats[seat].name,
peerId = if (kind == PlayerKind.AI) null else seats[seat].peerId,
)
_state.value = current.copy(seats = seats)
scope.launch { broadcastLobby() }
}
// -----------------------------------------------------------------------
// Joining
// -----------------------------------------------------------------------
fun startDiscovery() {
stop()
_state.value = State(joining = true, message = "Looking for games…")
for (transport in availableTransports()) {
jobs += scope.launch {
transport.discover(scope).collect { hosts ->
val others = _state.value.discovered.filter { it.kind != transport.kind }
_state.value = _state.value.copy(discovered = others + hosts)
}
}
}
}
fun join(host: DiscoveredHost, displayName: String, peerId: String) {
jobs += scope.launch {
_state.value = _state.value.copy(message = "Connecting to ${host.name}…")
val transport = transports.firstOrNull { it.kind == host.kind } ?: return@launch
val connection = runCatching { transport.join(host, scope) }.getOrElse { error ->
_state.value = _state.value.copy(message = "Couldn't connect: ${error.message}")
return@launch
}
clientLink = connection
launch {
connection.incoming.collect { message ->
when (message) {
is Welcome -> _state.value = if (message.accepted) {
_state.value.copy(
connected = true,
gameId = message.gameId,
hostName = message.hostName,
message = "Joined ${message.hostName}. Waiting for the host to start…",
)
} else {
_state.value.copy(message = message.reason ?: "The host declined.")
}
is LobbyUpdate -> _state.value = _state.value.copy(
seats = message.seats,
gameId = message.gameId,
optionsJson = message.optionsJson,
hostName = message.hostName,
)
is StartGame -> _state.value = _state.value.copy(started = message.config)
is Bye -> _state.value =
_state.value.copy(connected = false, message = "The host left.")
else -> Unit
}
}
}
connection.send(Hello(peerId = peerId, displayName = displayName))
}
}
fun clearStarted() {
_state.value = _state.value.copy(started = null)
}
fun stop() {
jobs.forEach { it.cancel() }
jobs.clear()
connections.values.forEach { runCatching { it.close() } }
connections.clear()
runCatching { clientLink?.close() }
clientLink = null
transports.forEach { runCatching { it.stop() } }
_state.value = State()
}
}
@@ -0,0 +1,264 @@
package org.prolibertate.games.net
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.prolibertate.games.game.engine.GameAi
import org.prolibertate.games.game.engine.GameRules
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.TableConfig
/**
* Drives one match, in either of two roles.
*
* As HOST it owns the authoritative state: it validates every move (local, AI
* or remote), advances the game, and pushes each participant a view redacted
* for their seat. As CLIENT it holds only what the host has sent and turns
* local input into [MoveIntent]s.
*
* A purely offline game against AI is just a host with no connections, so
* there is only one code path to get right.
*/
class MatchController<S : Any, M : Any>(
private val rules: GameRules<S, M>,
private val ai: GameAi<S, M>,
private val config: TableConfig,
private val scope: CoroutineScope,
val role: Role,
/** Seats played by a person on this device. */
private val localSeats: Set<Int>,
/** The local player's primary seat, used to pick the view to render. */
val primarySeat: Int,
/**
* Advances a state that nobody is waiting on — Euchre's gap between a
* scored hand and the next deal. Returning null means the game is over.
*/
private val advanceIdle: (S) -> S? = { null },
/** Scaled by the animation-speed setting so the table paces itself. */
private val aiThinkingMillis: () -> Long = { 700L },
) {
enum class Role { HOST, CLIENT }
private val _state = MutableStateFlow<S?>(null)
/** What the local player should see: redacted on the host, as-sent on a client. */
val state: StateFlow<S?> = _state.asStateFlow()
private val _legalMoves = MutableStateFlow<List<M>>(emptyList())
val legalMoves: StateFlow<List<M>> = _legalMoves.asStateFlow()
private val _finished = MutableStateFlow(false)
val finished: StateFlow<Boolean> = _finished.asStateFlow()
private val _notice = MutableStateFlow<String?>(null)
val notice: StateFlow<String?> = _notice.asStateFlow()
/** Host only: the true state. Clients never see this. */
private var authoritative: S? = null
private val lock = Mutex()
private var sequence = 0
private val peers = mutableMapOf<String, Connection>()
private var hostLink: Connection? = null
private val seatKinds: Map<Int, PlayerKind> =
config.seats.associate { it.seat to it.kind }
private val seatPeers: Map<Int, String?> =
config.seats.associate { it.seat to it.peerId }
// -----------------------------------------------------------------------
// Start-up
// -----------------------------------------------------------------------
/** Host: begin the match and start pushing state to [clients]. */
fun startAsHost(clients: List<Connection>) {
check(role == Role.HOST) { "Not hosting" }
clients.forEach { connection ->
peers[connection.peerId] = connection
scope.launch {
connection.incoming.collect { message -> onHostMessage(connection, message) }
}
}
scope.launch {
lock.withLock {
authoritative = rules.initialState(config)
publish()
}
driveIdleSeats()
}
}
/** Client: render whatever arrives from [link]. */
fun startAsClient(link: Connection) {
check(role == Role.CLIENT) { "Not a client" }
hostLink = link
scope.launch {
link.incoming.collect { message ->
when (message) {
is StateSync -> {
_state.value = rules.decodeState(message.stateJson)
_legalMoves.value = message.legalMoves.map { rules.decodeMove(it) }
_finished.value = _state.value?.let { rules.isFinished(it) } ?: false
}
is Rejected -> _notice.value = message.reason
is Bye -> _notice.value = "The host left the game."
else -> Unit
}
}
}
}
// -----------------------------------------------------------------------
// Input
// -----------------------------------------------------------------------
/** Called by the UI when the local player commits to a move. */
fun submit(move: M) {
scope.launch {
when (role) {
Role.HOST -> applyIfLegal(primarySeatOnClock(), move)
Role.CLIENT -> hostLink?.send(MoveIntent(rules.encodeMove(move)))
}
}
}
private fun primarySeatOnClock(): Int {
val current = authoritative?.let { rules.currentSeat(it) }
// With several local seats (pass-and-play or AI fill), the seat on the
// clock is the one submitting, provided it is actually ours.
return if (current != null && current in localSeats) current else primarySeat
}
private suspend fun onHostMessage(connection: Connection, message: NetMessage) {
if (message !is MoveIntent) return
val seat = seatPeers.entries.firstOrNull { it.value == connection.peerId }?.key
if (seat == null) {
connection.send(Rejected("You are not seated at this table."))
return
}
val move = runCatching { rules.decodeMove(message.moveJson) }.getOrNull()
if (move == null) {
connection.send(Rejected("Unreadable move."))
return
}
applyIfLegal(seat, move)
}
/**
* The single choke point where a move becomes real. Everything — local
* taps, AI picks and anything arriving over the wire — lands here and is
* re-checked against the rules.
*/
private suspend fun applyIfLegal(seat: Int, move: M) {
lock.withLock {
val current = authoritative ?: return
if (rules.currentSeat(current) != seat) {
seatPeers[seat]?.let { peers[it]?.send(Rejected("It is not your turn.")) }
return
}
if (move !in rules.legalMoves(current, seat)) {
seatPeers[seat]?.let { peers[it]?.send(Rejected("That move is not legal.")) }
return
}
authoritative = rules.applyMove(current, seat, move)
publish()
}
driveIdleSeats()
}
// -----------------------------------------------------------------------
// Host loop
// -----------------------------------------------------------------------
/**
* Plays out every seat the humans are not responsible for, then advances
* through any state nobody is waiting on, until a person is on the clock.
*/
private suspend fun driveIdleSeats() {
while (true) {
val current = lock.withLock { authoritative } ?: return
if (rules.isFinished(current)) {
_finished.value = true
return
}
val seat = rules.currentSeat(current)
if (seat == null) {
// Nobody on the clock: a hand has been scored, or the game ended.
delay(aiThinkingMillis() * 2)
val advanced = advanceIdle(current)
if (advanced == null) {
_finished.value = true
return
}
lock.withLock {
authoritative = advanced
publish()
}
continue
}
if (seatKinds[seat] != PlayerKind.AI) return // a person's turn
delay(aiThinkingMillis())
val chosen = lock.withLock {
val live = authoritative ?: return
if (rules.currentSeat(live) != seat) return@withLock null
val legal = rules.legalMoves(live, seat)
if (legal.isEmpty()) return@withLock null
val move = ai.chooseMove(live, seat, legal)
authoritative = rules.applyMove(live, seat, move)
publish()
move
}
if (chosen == null) return
}
}
/** Pushes the local view and every remote seat's view. Call under [lock]. */
private suspend fun publish() {
val current = authoritative ?: return
sequence++
_state.value = rules.viewFor(current, primarySeat)
_legalMoves.value = if (rules.currentSeat(current) in localSeats) {
rules.legalMoves(current, rules.currentSeat(current)!!)
} else {
emptyList()
}
_finished.value = rules.isFinished(current)
for (slot in config.seats) {
val peerId = slot.peerId ?: continue
val connection = peers[peerId] ?: continue
val seatView = rules.viewFor(current, slot.seat)
val moves = if (rules.currentSeat(current) == slot.seat) {
rules.legalMoves(current, slot.seat).map { rules.encodeMove(it) }
} else {
emptyList()
}
connection.send(
StateSync(
seq = sequence,
yourSeat = slot.seat,
stateJson = rules.encodeState(seatView),
legalMoves = moves,
)
)
}
}
fun close() {
peers.values.forEach { runCatching { it.close() } }
peers.clear()
runCatching { hostLink?.close() }
hostLink = null
}
}
@@ -0,0 +1,92 @@
package org.prolibertate.games.net
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
/**
* The wire protocol, shared by the LAN and Bluetooth transports.
*
* The host is authoritative: clients send [MoveIntent] and render whatever
* [StateSync] tells them. Nothing is trusted from a client beyond "I would like
* to make this move", which the host re-validates against the rules before it
* takes effect. Each client is sent a state redacted for its own seat, so an
* opponent's hand is never transmitted in the first place.
*/
const val PROTOCOL_VERSION = 1
/** Service type advertised over mDNS, and the RFCOMM service name. */
const val SERVICE_TYPE = "_plgames._tcp"
const val SERVICE_NAME = "ProLibertateGames"
/** Fixed RFCOMM UUID. Both ends must agree; this is ours. */
const val RFCOMM_UUID = "8f1d5a20-3a4e-4b26-9d31-6c0d2f7a91b4"
val protocolJson: Json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
classDiscriminator = "t"
}
@Serializable
sealed interface NetMessage
/** First message a joining client sends. */
@Serializable
@SerialName("hello")
data class Hello(
val peerId: String,
val displayName: String,
val protocol: Int = PROTOCOL_VERSION,
) : NetMessage
/** Host's reply, or a refusal if the lobby is full or the versions differ. */
@Serializable
@SerialName("welcome")
data class Welcome(
val hostName: String,
val gameId: String,
val accepted: Boolean,
val reason: String? = null,
) : NetMessage
/** Broadcast whenever the seat list or options change. */
@Serializable
@SerialName("lobby")
data class LobbyUpdate(
val gameId: String,
val seats: List<PlayerSlot>,
val optionsJson: String,
val hostName: String,
) : NetMessage
/** Host starts the match; everyone builds the same initial state from this. */
@Serializable
@SerialName("start")
data class StartGame(val config: TableConfig) : NetMessage
/** A client's redacted view of the authoritative state. */
@Serializable
@SerialName("state")
data class StateSync(
val seq: Int,
val yourSeat: Int,
val stateJson: String,
/** Encoded moves this seat may currently make. Empty when it is not its turn. */
val legalMoves: List<String>,
) : NetMessage
/** A client asking to play a move. The host validates it. */
@Serializable
@SerialName("intent")
data class MoveIntent(val moveJson: String) : NetMessage
@Serializable
@SerialName("rejected")
data class Rejected(val reason: String) : NetMessage
@Serializable
@SerialName("bye")
data object Bye : NetMessage
@@ -0,0 +1,137 @@
package org.prolibertate.games.net
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.Closeable
import java.io.InputStream
import java.io.OutputStream
import java.util.concurrent.atomic.AtomicBoolean
enum class TransportKind(val label: String) {
LAN("Wi-Fi"),
BLUETOOTH("Bluetooth"),
}
/**
* A host someone could join, found by one of the transports. [address] is
* transport-specific: an IP for LAN, a MAC for Bluetooth.
*/
data class DiscoveredHost(
val id: String,
val name: String,
val kind: TransportKind,
val address: String,
val port: Int = 0,
)
/**
* One peer-to-peer link carrying [NetMessage]s.
*
* Both transports end up with a plain pair of streams, so the framing and
* serialisation live here once rather than twice.
*/
interface Connection : Closeable {
val peerId: String
val kind: TransportKind
val incoming: SharedFlow<NetMessage>
suspend fun send(message: NetMessage)
val isOpen: Boolean
}
/**
* Newline-delimited JSON over a stream pair.
*
* Line framing keeps this debuggable — you can watch a session with netcat —
* and JSON payloads never contain a raw newline, so the framing is safe.
*/
class StreamConnection(
override val peerId: String,
override val kind: TransportKind,
private val input: InputStream,
private val output: OutputStream,
private val scope: CoroutineScope,
private val onClosed: (StreamConnection) -> Unit = {},
) : Connection {
private val reader: BufferedReader = input.bufferedReader()
private val writer: BufferedWriter = output.bufferedWriter()
private val closed = AtomicBoolean(false)
private val _incoming = MutableSharedFlow<NetMessage>(replay = 0, extraBufferCapacity = 64)
override val incoming: SharedFlow<NetMessage> = _incoming
override val isOpen: Boolean get() = !closed.get()
init {
scope.launch(Dispatchers.IO) {
try {
while (isActive && !closed.get()) {
val line = reader.readLine() ?: break
if (line.isBlank()) continue
val message = runCatching {
protocolJson.decodeFromString<NetMessage>(line)
}.getOrNull() ?: continue // ignore anything we can't parse
_incoming.emit(message)
}
} catch (_: Exception) {
// A dropped link is normal: someone walked out of range or
// closed the app. Fall through to close().
} finally {
close()
}
}
}
override suspend fun send(message: NetMessage) {
if (closed.get()) return
withContext(Dispatchers.IO) {
try {
writer.write(protocolJson.encodeToString<NetMessage>(message))
writer.newLine()
writer.flush()
} catch (_: Exception) {
close()
}
}
}
override fun close() {
if (!closed.compareAndSet(false, true)) return
runCatching { reader.close() }
runCatching { writer.close() }
runCatching { input.close() }
runCatching { output.close() }
onClosed(this)
}
}
/**
* Discovery and connection setup. Everything after the handshake is the same
* for both transports, which is what lets a lobby accept players over Wi-Fi and
* Bluetooth at the same time.
*/
interface Transport {
val kind: TransportKind
/** Whether the radio exists and is usable right now. */
fun isAvailable(): Boolean
/** Starts advertising and emits each client that connects. */
fun host(displayName: String, scope: CoroutineScope): Flow<Connection>
/** Emits the current list of visible hosts as it changes. */
fun discover(scope: CoroutineScope): Flow<List<DiscoveredHost>>
suspend fun join(host: DiscoveredHost, scope: CoroutineScope): Connection
fun stop()
}
@@ -0,0 +1,74 @@
package org.prolibertate.games.settings
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
/**
* User settings.
*
* [animationSpeed] is a multiplier rather than a duration so every animated
* thing in the app can scale off one number: 0.5x is slow and deliberate, 2x
* is for people who already know the rules.
*/
data class Settings(
val soundEnabled: Boolean = true,
val animationSpeed: Float = 1.0f,
val checkForUpdatesOnLaunch: Boolean = true,
val playerName: String = "Player",
) {
/** Scales a nominal duration by the chosen speed. */
fun scaled(millis: Long): Long =
(millis / animationSpeed.coerceIn(MIN_SPEED, MAX_SPEED)).toLong()
companion object {
const val MIN_SPEED = 0.5f
const val MAX_SPEED = 2.0f
}
}
class SettingsRepository(private val context: Context) {
private object Keys {
val SOUND = booleanPreferencesKey("sound_enabled")
val SPEED = floatPreferencesKey("animation_speed")
val UPDATE_ON_LAUNCH = booleanPreferencesKey("check_updates_on_launch")
val NAME = stringPreferencesKey("player_name")
}
val settings: Flow<Settings> = context.dataStore.data.map { prefs ->
Settings(
soundEnabled = prefs[Keys.SOUND] ?: true,
animationSpeed = prefs[Keys.SPEED] ?: 1.0f,
checkForUpdatesOnLaunch = prefs[Keys.UPDATE_ON_LAUNCH] ?: true,
playerName = prefs[Keys.NAME] ?: "Player",
)
}
suspend fun setSoundEnabled(enabled: Boolean) {
context.dataStore.edit { it[Keys.SOUND] = enabled }
}
suspend fun setAnimationSpeed(speed: Float) {
context.dataStore.edit {
it[Keys.SPEED] = speed.coerceIn(Settings.MIN_SPEED, Settings.MAX_SPEED)
}
}
suspend fun setCheckForUpdatesOnLaunch(enabled: Boolean) {
context.dataStore.edit { it[Keys.UPDATE_ON_LAUNCH] = enabled }
}
suspend fun setPlayerName(name: String) {
context.dataStore.edit { it[Keys.NAME] = name.trim().ifBlank { "Player" } }
}
}
@@ -0,0 +1,275 @@
package org.prolibertate.games.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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.unit.dp
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.GameCategory
import org.prolibertate.games.game.GameDescriptor
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.Updater
/** Everything the screens need, assembled once by the activity. */
class AppEnv(
val settingsRepository: SettingsRepository,
val updater: Updater,
val lobby: LobbyController,
val peerId: String,
val requestBluetoothPermissions: () -> Unit,
)
/**
* Where the app currently is.
*
* A hand-rolled stack rather than the navigation library: there are six
* destinations, two of which carry a live [TableConfig], and a typed sealed
* class is both smaller and harder to get wrong than argument encoding.
*/
sealed interface Route {
data object Menu : Route
data object Settings : Route
data class Setup(val gameId: String) : Route
data class Lobby(val gameId: String, val optionsJson: String, val hosting: Boolean) : Route
data class Play(
val gameId: String,
val config: TableConfig,
val hosting: Boolean,
val localSeat: Int,
) : Route
}
@Composable
fun AppRoot(env: AppEnv) {
val settings by env.settingsRepository.settings.collectAsState(initial = Settings())
var stack by remember { mutableStateOf(listOf<Route>(Route.Menu)) }
fun push(route: Route) {
stack = stack + route
}
fun pop() {
if (stack.size > 1) {
// Leaving a lobby or a table tears the networking down with it.
when (stack.last()) {
is Route.Lobby, is Route.Play -> env.lobby.stop()
else -> Unit
}
stack = stack.dropLast(1)
}
}
BackHandler(enabled = stack.size > 1) { pop() }
when (val route = stack.last()) {
is Route.Menu -> MainMenuScreen(
onPickGame = { push(Route.Setup(it.id)) },
onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) },
onSettings = { push(Route.Settings) },
)
is Route.Settings -> SettingsScreen(
env = env,
settings = settings,
onBack = { pop() },
)
is Route.Setup -> GameSetupScreen(
descriptor = GameCatalog.byId(route.gameId)!!,
playerName = settings.playerName,
onBack = { pop() },
onPlayOffline = { config -> push(Route.Play(route.gameId, config, true, 0)) },
onHostOnline = { optionsJson ->
push(Route.Lobby(route.gameId, optionsJson, hosting = true))
},
)
is Route.Lobby -> LobbyScreen(
env = env,
route = route,
playerName = settings.playerName,
onBack = { pop() },
onStart = { config, hosting, seat ->
push(Route.Play(config.gameId, config, hosting, seat))
},
)
is Route.Play -> PlayScreen(
env = env,
route = route,
settings = settings,
onExit = { pop() },
)
}
}
// ---------------------------------------------------------------------------
// Main menu
// ---------------------------------------------------------------------------
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainMenuScreen(
onPickGame: (GameDescriptor) -> Unit,
onJoinGame: () -> Unit,
onSettings: () -> Unit,
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Pro Libertate Games") },
actions = { TextButton(onClick = onSettings) { Text("Settings") } },
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 12.dp),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(onClick = onJoinGame) { Text("Join a game nearby") }
}
// The column count follows the available width, so a phone in
// portrait gets two and a tablet in landscape gets four or more.
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 172.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxSize(),
) {
GameCategory.entries.forEach { category ->
item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
Text(
text = category.label,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
)
}
items(GameCatalog.byCategory(category)) { game ->
GameTile(game = game, onClick = { onPickGame(game) })
}
}
}
}
}
}
@Composable
private fun GameTile(game: GameDescriptor, onClick: () -> Unit) {
Card(
onClick = onClick,
enabled = game.available,
colors = CardDefaults.cardColors(
containerColor = if (game.available) {
MaterialTheme.colorScheme.surfaceVariant
} else {
MaterialTheme.colorScheme.surface
}
),
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = game.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text = game.blurb,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 4.dp),
)
Text(
text = if (game.available) {
if (game.minPlayers == game.maxPlayers) {
"${game.minPlayers} players"
} else {
"${game.minPlayers}–${game.maxPlayers} players"
}
} else {
"Coming soon"
},
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(top = 8.dp),
)
}
}
}
// ---------------------------------------------------------------------------
// Shared chrome
// ---------------------------------------------------------------------------
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ScreenScaffold(
title: String,
onBack: () -> Unit,
actions: @Composable () -> Unit = {},
content: @Composable (Modifier) -> Unit,
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(title) },
navigationIcon = { TextButton(onClick = onBack) { Text("Back") } },
actions = { actions() },
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.TopStart,
) {
content(Modifier.fillMaxSize().padding(12.dp))
}
}
}
@Composable
fun PrimaryAction(text: String, enabled: Boolean = true, onClick: () -> Unit) {
Button(onClick = onClick, enabled = enabled, modifier = Modifier.fillMaxWidth()) {
Text(text)
}
}
@@ -0,0 +1,240 @@
package org.prolibertate.games.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Divider
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.GameDescriptor
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
import org.prolibertate.games.game.euchre.EuchreOptions
import org.prolibertate.games.game.sequence.SequenceOptions
private val setupJson = Json { encodeDefaults = true }
/**
* Choose who is playing and which house rules apply.
*
* Rule variations are per game and live here rather than in a global settings
* screen, because they change what the engine does rather than how it looks.
*/
@Composable
fun GameSetupScreen(
descriptor: GameDescriptor,
playerName: String,
onBack: () -> Unit,
onPlayOffline: (TableConfig) -> Unit,
onHostOnline: (String) -> Unit,
) {
var euchre by remember { mutableStateOf(EuchreOptions()) }
var sequence by remember { mutableStateOf(SequenceOptions()) }
val optionsJson = when (descriptor.id) {
GameCatalog.EUCHRE -> setupJson.encodeToString(euchre)
GameCatalog.SEQUENCE -> setupJson.encodeToString(sequence)
else -> "{}"
}
val seatCount = when (descriptor.id) {
GameCatalog.EUCHRE -> 4
GameCatalog.SEQUENCE -> sequence.playerCount
else -> descriptor.minPlayers
}
ScreenScaffold(title = descriptor.title, onBack = onBack) { modifier ->
Column(
modifier = modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(descriptor.blurb, style = MaterialTheme.typography.bodyMedium)
Divider()
Text("House rules", fontWeight = FontWeight.Bold)
when (descriptor.id) {
GameCatalog.EUCHRE -> EuchreOptionsEditor(euchre) { euchre = it }
GameCatalog.SEQUENCE -> SequenceOptionsEditor(sequence) { sequence = it }
else -> Text("No options yet for this game.")
}
Divider()
Text("Opponents", fontWeight = FontWeight.Bold)
Text(
text = "Play now and every other seat is taken by the computer. Host a game " +
"instead and people can claim those seats as they join — whatever is " +
"still empty when you start stays computer-controlled.",
style = MaterialTheme.typography.bodySmall,
)
PrimaryAction(text = "Play against the computer") {
onPlayOffline(
offlineConfig(descriptor.id, optionsJson, seatCount, playerName)
)
}
PrimaryAction(text = "Host a game for others to join") {
onHostOnline(optionsJson)
}
}
}
}
/** Seat 0 is the local player; the rest start as AI. */
private fun offlineConfig(
gameId: String,
optionsJson: String,
seatCount: Int,
playerName: String,
): TableConfig = TableConfig(
gameId = gameId,
seats = (0 until seatCount).map { seat ->
PlayerSlot(
seat = seat,
name = if (seat == 0) playerName else "Computer $seat",
kind = if (seat == 0) PlayerKind.HUMAN_LOCAL else PlayerKind.AI,
team = teamForSeat(gameId, seat),
)
},
optionsJson = optionsJson,
// Seeded from the clock so consecutive games are not identical.
seed = System.currentTimeMillis(),
)
fun teamForSeat(gameId: String, seat: Int): Int = when (gameId) {
// Partners sit opposite each other.
GameCatalog.EUCHRE -> seat % 2
else -> seat % 2
}
@Composable
private fun EuchreOptionsEditor(options: EuchreOptions, onChange: (EuchreOptions) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ChipRow(
label = "Deck",
values = listOf(24, 32),
selected = options.deckSize,
display = { "$it cards" },
onSelect = { onChange(options.copy(deckSize = it)) },
)
ChipRow(
label = "Game to",
values = listOf(10, 11, 15),
selected = options.pointsToWin,
display = { "$it points" },
onSelect = { onChange(options.copy(pointsToWin = it)) },
)
ToggleRow(
title = "Stick the dealer",
subtitle = "If everyone passes twice, the dealer must name trump",
checked = options.stickTheDealer,
onChange = { onChange(options.copy(stickTheDealer = it)) },
)
ToggleRow(
title = "Allow going alone",
subtitle = "A maker may sit their partner out for a bigger score",
checked = options.allowGoingAlone,
onChange = { onChange(options.copy(allowGoingAlone = it)) },
)
}
}
@Composable
private fun SequenceOptionsEditor(options: SequenceOptions, onChange: (SequenceOptions) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ChipRow(
label = "Teams",
values = listOf(2, 3),
selected = options.teamCount,
display = { "$it teams" },
onSelect = { teams ->
// Three teams race to a single sequence; two teams need two.
onChange(options.copy(teamCount = teams, sequencesToWin = if (teams == 3) 1 else 2))
},
)
ChipRow(
label = "Players per team",
values = listOf(1, 2, 3, 4),
selected = options.playersPerTeam,
display = { "$it" },
onSelect = { onChange(options.copy(playersPerTeam = it)) },
)
ChipRow(
label = "Sequences to win",
values = listOf(1, 2, 3),
selected = options.sequencesToWin,
display = { "$it" },
onSelect = { onChange(options.copy(sequencesToWin = it)) },
)
ToggleRow(
title = "Dead card exchange",
subtitle = "Swap a card whose squares are both already taken",
checked = options.deadCardExchange,
onChange = { onChange(options.copy(deadCardExchange = it)) },
)
Text(
text = "${options.playerCount} players in total.",
style = MaterialTheme.typography.bodySmall,
)
}
}
@Composable
private fun <T> ChipRow(
label: String,
values: List<T>,
selected: T,
display: (T) -> String,
onSelect: (T) -> Unit,
) {
Column {
Text(label, style = MaterialTheme.typography.labelLarge)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
values.forEach { value ->
FilterChip(
selected = value == selected,
onClick = { onSelect(value) },
label = { Text(display(value)) },
)
}
}
}
}
@Composable
private fun ToggleRow(
title: String,
subtitle: String,
checked: Boolean,
onChange: (Boolean) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f).padding(end = 8.dp)) {
Text(title)
Text(subtitle, style = MaterialTheme.typography.bodySmall)
}
Switch(checked = checked, onCheckedChange = onChange)
}
}
@@ -0,0 +1,187 @@
package org.prolibertate.games.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.Divider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.TableConfig
import org.prolibertate.games.net.DiscoveredHost
import org.prolibertate.games.net.LobbyController
/**
* Hosting or joining a table.
*
* A host advertises on Wi-Fi and Bluetooth simultaneously, and the seat list
* does not care which one a given player arrived on.
*/
@Composable
fun LobbyScreen(
env: AppEnv,
route: Route.Lobby,
playerName: String,
onBack: () -> Unit,
onStart: (TableConfig, Boolean, Int) -> Unit,
) {
val state by env.lobby.state.collectAsState()
LaunchedEffect(route) {
env.requestBluetoothPermissions()
if (route.hosting) {
val descriptor = GameCatalog.byId(route.gameId)
val seatCount = descriptor?.let {
if (it.id == GameCatalog.EUCHRE) 4 else it.minPlayers
} ?: 4
env.lobby.startHosting(
gameId = route.gameId,
optionsJson = route.optionsJson,
hostName = playerName,
seatCount = seatCount,
teamOf = { seat -> teamForSeat(route.gameId, seat) },
)
} else {
env.lobby.startDiscovery()
}
}
// Both roles leave for the table the moment the host commits.
LaunchedEffect(state.started) {
val config = state.started ?: return@LaunchedEffect
val seat = if (route.hosting) {
0
} else {
config.seats.firstOrNull { it.peerId == env.peerId }?.seat
?: config.seats.firstOrNull { it.kind == PlayerKind.HUMAN_REMOTE }?.seat
?: 0
}
env.lobby.clearStarted()
onStart(config, route.hosting, seat)
}
val title = if (route.hosting) "Hosting" else "Join a game"
ScreenScaffold(title = title, onBack = onBack) { modifier ->
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(12.dp)) {
val available = env.lobby.availableTransports()
Text(
text = if (available.isEmpty()) {
"No Wi-Fi or Bluetooth available. Turn one on to play with others."
} else {
(if (route.hosting) "Visible over " else "Searching over ") +
available.joinToString(" and ") { it.kind.label } + "."
},
style = MaterialTheme.typography.bodySmall,
)
state.message?.let { Text(it, style = MaterialTheme.typography.bodyMedium) }
Divider()
if (route.hosting) {
HostingBody(env.lobby, state)
} else {
JoiningBody(env, state.discovered, playerName)
}
}
}
}
@Composable
private fun HostingBody(lobby: LobbyController, state: LobbyController.State) {
Text("Seats", fontWeight = FontWeight.Bold)
LazyColumn(
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth(),
) {
items(state.seats) { slot ->
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column {
Text("Seat ${slot.seat} — ${slot.name}")
Text(
text = when (slot.kind) {
PlayerKind.HUMAN_LOCAL -> "You"
PlayerKind.HUMAN_REMOTE -> "Joined"
PlayerKind.AI -> "Computer"
} + " · Team ${slot.team}",
style = MaterialTheme.typography.bodySmall,
)
}
if (slot.kind == PlayerKind.HUMAN_REMOTE) {
TextButton(onClick = {
lobby.setSeatKind(slot.seat, PlayerKind.AI, "Computer ${slot.seat}")
}) { Text("Drop") }
}
}
}
}
}
PrimaryAction(
text = "Start game",
enabled = state.seats.isNotEmpty(),
) {
lobby.startMatch(seed = System.currentTimeMillis())
}
Text(
text = "Any seat still showing Computer will be played by the AI.",
style = MaterialTheme.typography.bodySmall,
)
}
@Composable
private fun JoiningBody(
env: AppEnv,
discovered: List<DiscoveredHost>,
playerName: String,
) {
if (discovered.isEmpty()) {
Text("No games found yet. Make sure the host has started theirs.")
Text(
text = "Bluetooth only shows devices already paired in Android's " +
"Bluetooth settings.",
style = MaterialTheme.typography.bodySmall,
)
return
}
LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
items(discovered) { host ->
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { env.lobby.join(host, playerName, env.peerId) }
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(host.name, fontWeight = FontWeight.Bold)
Text(
text = "over ${host.kind.label}",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
}
@@ -0,0 +1,104 @@
package org.prolibertate.games.ui
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.euchre.EuchreAi
import org.prolibertate.games.game.euchre.EuchreMove
import org.prolibertate.games.game.euchre.EuchrePhase
import org.prolibertate.games.game.euchre.EuchreRules
import org.prolibertate.games.game.euchre.EuchreState
import org.prolibertate.games.game.sequence.SequenceAi
import org.prolibertate.games.game.sequence.SequenceMove
import org.prolibertate.games.game.sequence.SequenceRules
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.SequenceScreen
/**
* Builds the match for the chosen game and hands it to that game's screen.
*
* Offline play and hosting are the same thing here — an offline game is simply
* a host with no connections — so there is one path through this code whether
* or not anyone else is at the table.
*/
@Composable
fun PlayScreen(
env: AppEnv,
route: Route.Play,
settings: Settings,
onExit: () -> Unit,
) {
val scope = rememberCoroutineScope()
val role = if (route.hosting) MatchController.Role.HOST else MatchController.Role.CLIENT
when (route.gameId) {
GameCatalog.EUCHRE -> {
val controller = remember(route) {
MatchController<EuchreState, EuchreMove>(
rules = EuchreRules,
ai = EuchreAi(),
config = route.config,
scope = scope,
role = role,
localSeats = setOf(route.localSeat),
primarySeat = route.localSeat,
// A scored hand pauses on screen, then the next one is dealt.
advanceIdle = { state ->
if (state.phase == EuchrePhase.HAND_OVER) {
EuchreRules.nextHand(state)
} else {
null
}
},
aiThinkingMillis = { settings.scaled(700L) },
)
}
StartMatch(env, controller, route)
EuchreScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
}
GameCatalog.SEQUENCE -> {
val controller = remember(route) {
MatchController<SequenceState, SequenceMove>(
rules = SequenceRules,
ai = SequenceAi(),
config = route.config,
scope = scope,
role = role,
localSeats = setOf(route.localSeat),
primarySeat = route.localSeat,
aiThinkingMillis = { settings.scaled(700L) },
)
}
StartMatch(env, controller, route)
SequenceScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
}
else -> Text("That game isn't playable yet.")
}
}
@Composable
private fun <S : Any, M : Any> StartMatch(
env: AppEnv,
controller: MatchController<S, M>,
route: Route.Play,
) {
LaunchedEffect(controller) {
if (route.hosting) {
controller.startAsHost(env.lobby.hostConnections)
} else {
env.lobby.clientConnection?.let { controller.startAsClient(it) }
}
}
DisposableEffect(controller) {
onDispose { controller.close() }
}
}
@@ -0,0 +1,180 @@
package org.prolibertate.games.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Divider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.unit.dp
import kotlinx.coroutines.launch
import org.prolibertate.games.settings.Settings
import org.prolibertate.games.update.Updater
@Composable
fun SettingsScreen(
env: AppEnv,
settings: Settings,
onBack: () -> Unit,
) {
val scope = rememberCoroutineScope()
var name by remember(settings.playerName) { mutableStateOf(settings.playerName) }
var updateStatus by remember { mutableStateOf<String?>(null) }
var pendingRelease by remember { mutableStateOf<Updater.Release?>(null) }
ScreenScaffold(title = "Settings", onBack = onBack) { modifier ->
Column(
modifier = modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
SettingRow(
title = "Sound",
subtitle = "Card and chip effects",
) {
Switch(
checked = settings.soundEnabled,
onCheckedChange = { enabled ->
scope.launch { env.settingsRepository.setSoundEnabled(enabled) }
},
)
}
Divider()
Column {
Text("Animation speed", fontWeight = FontWeight.Bold)
Text(
text = "${"%.1f".format(settings.animationSpeed)}× — " +
"how quickly cards move and the computer takes its turn",
style = MaterialTheme.typography.bodySmall,
)
Slider(
value = settings.animationSpeed,
onValueChange = { speed ->
scope.launch { env.settingsRepository.setAnimationSpeed(speed) }
},
valueRange = Settings.MIN_SPEED..Settings.MAX_SPEED,
// Half-step increments across 0.5x-2.0x.
steps = 5,
)
}
Divider()
Column {
Text("Your name", fontWeight = FontWeight.Bold)
Text(
"Shown to other players in a lobby",
style = MaterialTheme.typography.bodySmall,
)
OutlinedTextField(
value = name,
onValueChange = { entered ->
name = entered
scope.launch { env.settingsRepository.setPlayerName(entered) }
},
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
}
Divider()
Text("Updates", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium)
Text(
text = "Installed: v${env.updater.installedVersionName()}. Updates come from " +
"the latest release published on the project's main branch.",
style = MaterialTheme.typography.bodySmall,
)
SettingRow(
title = "Check on launch",
subtitle = "Look for a newer release when the app starts",
) {
Switch(
checked = settings.checkForUpdatesOnLaunch,
onCheckedChange = { enabled ->
scope.launch { env.settingsRepository.setCheckForUpdatesOnLaunch(enabled) }
},
)
}
PrimaryAction(text = "Check for updates now") {
scope.launch {
updateStatus = "Checking…"
when (val result = env.updater.check()) {
is Updater.Result.UpToDate -> {
updateStatus = "You're on the latest version."
pendingRelease = null
}
is Updater.Result.Available -> {
pendingRelease = result.release
updateStatus = "Version ${result.release.tag} is available."
}
is Updater.Result.Failed -> {
updateStatus = result.reason
pendingRelease = null
}
}
}
}
pendingRelease?.let { release ->
PrimaryAction(text = "Download and install ${release.tag}") {
scope.launch {
if (env.updater.needsInstallPermission()) {
updateStatus =
"Allow installs from this app, then tap install again."
env.updater.requestInstallPermission()
} else {
updateStatus = "Downloading…"
val error = env.updater.downloadAndInstall(release)
updateStatus = error ?: "Handing over to the installer…"
}
}
}
}
updateStatus?.let {
Text(it, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
@Composable
private fun SettingRow(
title: String,
subtitle: String,
control: @Composable () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(title, fontWeight = FontWeight.Bold)
Text(subtitle, style = MaterialTheme.typography.bodySmall)
}
control()
}
}
@@ -0,0 +1,106 @@
package org.prolibertate.games.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.ui.theme.CardBlack
import org.prolibertate.games.ui.theme.CardRed
/**
* A playing card.
*
* Sized by width with a fixed 5:7 aspect ratio so the same composable works
* from a phone in portrait up to a tablet in landscape — callers pass a width
* derived from the space they actually have rather than a hard-coded dp.
*/
@Composable
fun PlayingCardView(
card: Card,
modifier: Modifier = Modifier,
width: Dp = 64.dp,
selected: Boolean = false,
enabled: Boolean = true,
onClick: (() -> Unit)? = null,
) {
val ink = if (card.suit.isRed) CardRed else CardBlack
Box(
modifier = modifier
.width(width)
.aspectRatio(CARD_ASPECT)
.clip(RoundedCornerShape(width * 0.12f))
.background(Color.White)
.border(
width = if (selected) 3.dp else 1.dp,
color = if (selected) MaterialTheme.colorScheme.primary else Color(0x33000000),
shape = RoundedCornerShape(width * 0.12f),
)
.alpha(if (enabled) 1f else 0.45f)
.then(
if (onClick != null && enabled) Modifier.clickable { onClick() } else Modifier
),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = card.rank.short,
color = ink,
fontWeight = FontWeight.Bold,
fontSize = (width.value * 0.34f).sp,
)
Text(
text = card.suit.symbol,
color = ink,
fontSize = (width.value * 0.34f).sp,
)
}
}
}
/** The back of a card, for hands you are not allowed to see. */
@Composable
fun CardBackView(
modifier: Modifier = Modifier,
width: Dp = 64.dp,
) {
Box(
modifier = modifier
.width(width)
.aspectRatio(CARD_ASPECT)
.clip(RoundedCornerShape(width * 0.12f))
.background(MaterialTheme.colorScheme.primary)
.border(1.dp, Color(0x33000000), RoundedCornerShape(width * 0.12f)),
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(width * 0.12f)
.border(1.dp, Color(0x55FFFFFF), RoundedCornerShape(width * 0.06f)),
)
}
}
const val CARD_ASPECT = 5f / 7f
@@ -0,0 +1,282 @@
package org.prolibertate.games.ui.game
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.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.horizontalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.euchre.CallTrump
import org.prolibertate.games.game.euchre.Discard
import org.prolibertate.games.game.euchre.EuchreMove
import org.prolibertate.games.game.euchre.EuchrePhase
import org.prolibertate.games.game.euchre.EuchreState
import org.prolibertate.games.game.euchre.OrderUp
import org.prolibertate.games.game.euchre.Pass
import org.prolibertate.games.game.euchre.PlayCard
import org.prolibertate.games.game.euchre.teamOf
import org.prolibertate.games.net.MatchController
import org.prolibertate.games.ui.PrimaryAction
import org.prolibertate.games.ui.ScreenScaffold
import org.prolibertate.games.ui.common.CardBackView
import org.prolibertate.games.ui.common.PlayingCardView
import org.prolibertate.games.ui.theme.FeltGreenDark
/**
* The Euchre table.
*
* Layout is driven by the space available rather than by orientation checks:
* card width is a fraction of the narrower dimension, so the same code lays out
* sensibly on a phone held either way and on a tablet.
*/
@Composable
fun EuchreScreen(
controller: MatchController<EuchreState, EuchreMove>,
localSeat: Int,
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val legal by controller.legalMoves.collectAsState()
val notice by controller.notice.collectAsState()
val current = state
if (current == null) {
ScreenScaffold(title = "Euchre", onBack = onExit) { modifier ->
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
}
ScreenScaffold(title = "Euchre", onBack = onExit) { modifier ->
BoxWithConstraints(modifier = modifier) {
val compact = maxWidth < 600.dp
val cardWidth = (minOf(maxWidth, maxHeight) * if (compact) 0.17f else 0.12f)
.coerceIn(48.dp, 104.dp)
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ScoreBar(current, localSeat)
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(FeltGreenDark),
contentAlignment = Alignment.Center,
) {
TrickArea(current, cardWidth)
}
notice?.let {
Text(it, style = MaterialTheme.typography.bodySmall)
}
when (current.phase) {
EuchrePhase.BID_ROUND_1,
EuchrePhase.BID_ROUND_2,
-> BiddingControls(current, legal) { controller.submit(it) }
EuchrePhase.HAND_OVER -> Text(
text = current.log.lastOrNull().orEmpty(),
style = MaterialTheme.typography.titleSmall,
)
EuchrePhase.GAME_OVER -> {
Text("Game over.", fontWeight = FontWeight.Bold)
PrimaryAction(text = "Back to the menu") { onExit() }
}
else -> Unit
}
HandRow(
state = current,
seat = localSeat,
legal = legal,
cardWidth = cardWidth,
onPlay = { controller.submit(it) },
)
}
}
}
}
@Composable
private fun ScoreBar(state: EuchreState, localSeat: Int) {
val myTeam = teamOf(localSeat)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text("Us ${state.scores[myTeam]} — Them ${state.scores[1 - myTeam]}",
fontWeight = FontWeight.Bold)
val tricks = state.tricksWon.filterIndexed { seat, _ -> teamOf(seat) == myTeam }.sum()
Text("Tricks this hand: $tricks", style = MaterialTheme.typography.bodySmall)
}
Column(horizontalAlignment = Alignment.End) {
Text(
text = state.trump?.let { "Trump ${it.symbol}" } ?: "Bidding",
fontWeight = FontWeight.Bold,
)
state.aloneSeat?.let {
Text("Seat $it is alone", style = MaterialTheme.typography.bodySmall)
}
}
}
}
@Composable
private fun TrickArea(state: EuchreState, cardWidth: androidx.compose.ui.unit.Dp) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
state.upCard?.let { up ->
Text("Turned up", color = MaterialTheme.colorScheme.onPrimary)
PlayingCardView(card = up, width = cardWidth)
}
if (state.trick.isNotEmpty()) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
state.trick.forEach { played ->
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "Seat ${played.seat}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary,
)
PlayingCardView(card = played.card, width = cardWidth)
}
}
}
} else if (state.upCard == null && state.phase == EuchrePhase.PLAYING) {
Text("Lead a card", color = MaterialTheme.colorScheme.onPrimary)
}
// Opponents' hands are only ever card counts on this device.
Row(
modifier = Modifier.padding(top = 12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
state.handCounts.forEachIndexed { seat, count ->
if (seat != state.turn) return@forEachIndexed
repeat(count.coerceAtMost(5)) {
CardBackView(width = cardWidth * 0.4f)
}
}
}
}
}
@Composable
private fun BiddingControls(
state: EuchreState,
legal: List<EuchreMove>,
onMove: (EuchreMove) -> Unit,
) {
if (legal.isEmpty()) {
Text("Waiting for seat ${state.turn}…")
return
}
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = if (state.phase == EuchrePhase.BID_ROUND_1) {
"Order up ${state.upCard?.label.orEmpty()}?"
} else {
"Name a suit"
},
fontWeight = FontWeight.Bold,
)
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
legal.forEach { move ->
val label = when (move) {
is Pass -> "Pass"
is OrderUp -> if (move.alone) "Order up alone" else "Order up"
is CallTrump -> {
(if (move.alone) "Alone " else "") + move.suit.symbol
}
is Discard -> "Discard ${move.card.label}"
is PlayCard -> move.card.label
}
if (move is Pass) {
OutlinedButton(onClick = { onMove(move) }) { Text(label) }
} else {
Button(onClick = { onMove(move) }) { Text(label) }
}
}
}
}
}
@Composable
private fun HandRow(
state: EuchreState,
seat: Int,
legal: List<EuchreMove>,
cardWidth: androidx.compose.ui.unit.Dp,
onPlay: (EuchreMove) -> Unit,
) {
val hand = state.hands.getOrNull(seat).orEmpty()
val playable = legal.filterIsInstance<PlayCard>().map { it.card }.toSet()
val discardable = legal.filterIsInstance<Discard>().map { it.card }.toSet()
Column {
Text(
text = when {
discardable.isNotEmpty() -> "Discard a card"
playable.isNotEmpty() -> "Your turn"
else -> "Your hand"
},
style = MaterialTheme.typography.labelLarge,
)
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
if (hand.isEmpty()) {
Box(modifier = Modifier.size(cardWidth)) {
Text("—", style = MaterialTheme.typography.bodySmall)
}
}
hand.forEach { card ->
val canPlay = card in playable
val canDiscard = card in discardable
PlayingCardView(
card = card,
width = cardWidth,
enabled = canPlay || canDiscard,
onClick = {
when {
canDiscard -> onPlay(Discard(card))
canPlay -> onPlay(PlayCard(card))
}
},
)
}
}
}
}
@@ -0,0 +1,296 @@
package org.prolibertate.games.ui.game
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
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.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
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.NO_TEAM
import org.prolibertate.games.game.sequence.PlaceChip
import org.prolibertate.games.game.sequence.RemoveChip
import org.prolibertate.games.game.sequence.SequenceBoard
import org.prolibertate.games.game.sequence.SequenceMove
import org.prolibertate.games.game.sequence.SequenceState
import org.prolibertate.games.net.MatchController
import org.prolibertate.games.ui.PrimaryAction
import org.prolibertate.games.ui.ScreenScaffold
import org.prolibertate.games.ui.theme.FeltGreenDark
import org.prolibertate.games.ui.theme.TeamColours
/**
* The Sequence board.
*
* The board is always square and always fits: its side is the smaller of the
* available width and the height left over after the hand, so rotating the
* device rescales rather than clipping.
*/
@Composable
fun SequenceScreen(
controller: MatchController<SequenceState, SequenceMove>,
localSeat: Int,
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val legal by controller.legalMoves.collectAsState()
var selected by remember { mutableStateOf<Card?>(null) }
val current = state
if (current == null) {
ScreenScaffold(title = "Sequence", onBack = onExit) { modifier ->
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
}
// Squares the selected card could legally claim or clear.
val placements = legal.filterIsInstance<PlaceChip>().filter { it.card == selected }
val removals = legal.filterIsInstance<RemoveChip>().filter { it.card == selected }
val highlighted = (placements.map { it.cell } + removals.map { it.cell }).toSet()
ScreenScaffold(title = "Sequence", onBack = onExit) { modifier ->
BoxWithConstraints(modifier = modifier) {
val wide = maxWidth > maxHeight
val boardSide = if (wide) {
minOf(maxWidth * 0.62f, maxHeight)
} else {
minOf(maxWidth, maxHeight * 0.68f)
}
val cell = boardSide / BOARD_SIZE
val board: @Composable () -> Unit = {
BoardGrid(
state = current,
cellSize = cell,
highlighted = highlighted,
onCellTap = { index ->
val placement = placements.firstOrNull { it.cell == index }
val removal = removals.firstOrNull { it.cell == index }
when {
placement != null -> {
controller.submit(placement)
selected = null
}
removal != null -> {
controller.submit(removal)
selected = null
}
}
},
)
}
val side: @Composable () -> Unit = {
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
StatusPanel(current, localSeat)
HandStrip(
state = current,
seat = localSeat,
legal = legal,
selected = selected,
cardWidth = (cell * 1.4f).coerceIn(40.dp, 84.dp),
onSelect = { card -> selected = if (selected == card) null else card },
onExchange = { controller.submit(it); selected = null },
)
if (controller.finished.collectAsState().value) {
PrimaryAction("Back to the menu") { onExit() }
}
}
}
if (wide) {
Row(modifier = Modifier.fillMaxSize()) {
board()
Box(modifier = Modifier.weight(1f)) { side() }
}
} else {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
board()
side()
}
}
}
}
}
@Composable
private fun BoardGrid(
state: SequenceState,
cellSize: Dp,
highlighted: Set<Int>,
onCellTap: (Int) -> Unit,
) {
Column(modifier = Modifier.background(FeltGreenDark)) {
for (row in 0 until BOARD_SIZE) {
Row {
for (col in 0 until BOARD_SIZE) {
val index = SequenceBoard.cellAt(row, col)
BoardCell(
card = SequenceBoard.cells[index],
team = state.chips[index],
locked = state.locked[index],
highlighted = index in highlighted,
size = cellSize,
onClick = { onCellTap(index) },
)
}
}
}
}
}
@Composable
private fun BoardCell(
card: Card?,
team: Int,
locked: Boolean,
highlighted: Boolean,
size: Dp,
onClick: () -> Unit,
) {
Box(
modifier = Modifier
.size(size)
.padding(0.5.dp)
.clip(RoundedCornerShape(size * 0.12f))
.background(if (card == null) MaterialTheme.colorScheme.primary else Color.White)
.border(
width = if (highlighted) 2.dp else 0.5.dp,
color = if (highlighted) MaterialTheme.colorScheme.tertiary else Color(0x33000000),
shape = RoundedCornerShape(size * 0.12f),
)
.clickable { onClick() },
contentAlignment = Alignment.Center,
) {
if (card == null) {
// A free corner, wild for every team.
Text("★", fontSize = (size.value * 0.45f).sp, color = Color.White)
} else {
Text(
text = card.label,
fontSize = (size.value * 0.26f).sp,
color = if (card.suit.isRed) Color(0xFFB3261E) else Color.Black,
)
}
if (team != NO_TEAM) {
Box(
modifier = Modifier
.size(size * 0.62f)
.clip(CircleShape)
.background(TeamColours.getOrElse(team) { Color.Gray })
.border(
width = if (locked) 2.dp else 0.dp,
color = if (locked) Color.White else Color.Transparent,
shape = CircleShape,
)
)
}
}
}
@Composable
private fun StatusPanel(state: SequenceState, localSeat: Int) {
val myTeam = state.teamOf(localSeat)
Column {
Text("You are team $myTeam", fontWeight = FontWeight.Bold)
state.sequencesByTeam.forEachIndexed { team, count ->
Text(
text = "Team $team: $count / ${state.options.sequencesToWin} sequences",
style = MaterialTheme.typography.bodySmall,
color = TeamColours.getOrElse(team) { Color.Gray },
)
}
state.winner?.let {
Text("Team $it wins!", fontWeight = FontWeight.Bold)
} ?: Text(
text = if (state.turn == localSeat) "Your turn" else "Seat ${state.turn} is playing…",
style = MaterialTheme.typography.bodyMedium,
)
}
}
@Composable
private fun HandStrip(
state: SequenceState,
seat: Int,
legal: List<SequenceMove>,
selected: Card?,
cardWidth: Dp,
onSelect: (Card) -> Unit,
onExchange: (ExchangeDeadCard) -> Unit,
) {
val hand = state.hands.getOrNull(seat).orEmpty()
val playable = legal.mapNotNull {
when (it) {
is PlaceChip -> it.card
is RemoveChip -> it.card
is ExchangeDeadCard -> it.card
}
}.toSet()
val exchanges = legal.filterIsInstance<ExchangeDeadCard>().associateBy { it.card }
Column {
Text("Your hand", style = MaterialTheme.typography.labelLarge)
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
hand.forEach { card ->
org.prolibertate.games.ui.common.PlayingCardView(
card = card,
width = cardWidth,
selected = card == selected,
enabled = card in playable,
onClick = {
val dead = exchanges[card]
if (dead != null) onExchange(dead) else onSelect(card)
},
)
}
}
selected?.let {
Text(
text = "Tap a highlighted square to play ${it.label}.",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
@@ -0,0 +1,50 @@
package org.prolibertate.games.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
/** Card-table green, which the board and table surfaces are drawn on. */
val FeltGreen = Color(0xFF1B5E20)
val FeltGreenDark = Color(0xFF0B3D12)
val Parchment = Color(0xFFF4EFE4)
val CardRed = Color(0xFFB3261E)
val CardBlack = Color(0xFF1C1B1F)
val TeamColours: List<Color> = listOf(
Color(0xFF1565C0), // blue
Color(0xFFC62828), // red
Color(0xFF2E7D32), // green, only used with three teams
)
private val DarkColours = darkColorScheme(
primary = Color(0xFF7BC67E),
onPrimary = Color(0xFF00390A),
secondary = Color(0xFFB9CCB4),
background = Color(0xFF10140F),
surface = Color(0xFF1A1F18),
surfaceVariant = Color(0xFF2A2F27),
)
private val LightColours = lightColorScheme(
primary = FeltGreen,
onPrimary = Color.White,
secondary = Color(0xFF52634F),
background = Parchment,
surface = Color(0xFFFBF8F1),
surfaceVariant = Color(0xFFE0E4D8),
)
@Composable
fun ProLibertateTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = if (darkTheme) DarkColours else LightColours,
content = content,
)
}
@@ -0,0 +1,127 @@
package org.prolibertate.games.update
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.core.content.FileProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
/**
* Over-the-air updates from the repository's GitHub Releases.
*
* Release tags are v1.0.<run-number> and the APK's versionCode is that same run
* number, so comparing the two is enough to tell whether a newer build exists.
* The repository's releases must be publicly downloadable — no token is
* embedded in the app, and GitHub blocks anonymous access to private assets.
*/
class Updater(private val activity: Activity) {
data class Release(val tag: String, val versionCode: Int, val apkUrl: String, val apkName: String)
sealed interface Result {
data class Available(val release: Release) : Result
data object UpToDate : Result
data class Failed(val reason: String) : Result
}
suspend fun check(): Result = withContext(Dispatchers.IO) {
try {
val latest = fetchLatestRelease()
?: return@withContext Result.Failed("No published release found.")
if (latest.versionCode <= installedVersionCode()) {
Result.UpToDate
} else {
Result.Available(latest)
}
} catch (e: Exception) {
Result.Failed(e.message ?: "Update check failed.")
}
}
private fun fetchLatestRelease(): Release? {
val url = URL("https://api.github.com/repos/$OWNER/$REPO/releases/latest")
val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
setRequestProperty("Accept", "application/vnd.github+json")
setRequestProperty("User-Agent", "ProLibertateGames-Updater")
connectTimeout = 15_000
readTimeout = 15_000
}
try {
if (connection.responseCode != 200) return null
val json = JSONObject(connection.inputStream.bufferedReader().use { it.readText() })
val tag = json.getString("tag_name")
val versionCode = tag.substringAfterLast('.').toIntOrNull() ?: return null
val assets = json.getJSONArray("assets")
for (i in 0 until assets.length()) {
val asset = assets.getJSONObject(i)
val name = asset.getString("name")
if (name.endsWith(".apk")) {
return Release(tag, versionCode, asset.getString("browser_download_url"), name)
}
}
return null
} finally {
connection.disconnect()
}
}
/** Downloads the APK and hands it to the system installer. */
suspend fun downloadAndInstall(release: Release): String? = withContext(Dispatchers.IO) {
try {
val target = File(activity.cacheDir, "update.apk")
URL(release.apkUrl).openStream().use { input ->
target.outputStream().use { output -> input.copyTo(output) }
}
withContext(Dispatchers.Main) { launchInstaller(target) }
null
} catch (e: Exception) {
e.message ?: "Download failed."
}
}
/** API 26+ requires the user to allow installs from this app first. */
fun needsInstallPermission(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
!activity.packageManager.canRequestPackageInstalls()
fun requestInstallPermission() {
runCatching {
activity.startActivity(
Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${activity.packageName}"),
)
)
}
}
private fun launchInstaller(apk: File) {
if (activity.isFinishing) return
val uri = FileProvider.getUriForFile(activity, "${activity.packageName}.fileprovider", apk)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
runCatching { activity.startActivity(intent) }
}
@Suppress("DEPRECATION")
fun installedVersionCode(): Int =
activity.packageManager.getPackageInfo(activity.packageName, 0).versionCode
fun installedVersionName(): String =
activity.packageManager.getPackageInfo(activity.packageName, 0).versionName ?: "unknown"
private companion object {
const val OWNER = "adrianoftyriel"
const val REPO = "ProLibertateGames"
}
}
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Solid grass-green field. -->
<path
android:fillColor="#2E7D32"
android:pathData="M0,0h108v108h-108z" />
</vector>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- A black hole in the center of the icon's safe zone. -->
<path
android:fillColor="#111111"
android:pathData="M54,54m-26,0a26,26 0,1 1,52 0a26,26 0,1 1,-52 0" />
<path
android:fillColor="#000000"
android:pathData="M54,54m-20,0a20,20 0,1 1,40 0a20,20 0,1 1,-40 0" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Fallback launcher icon for API < 26 (no adaptive-icon support). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#2E7D32"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#111111"
android:pathData="M54,54m-26,0a26,26 0,1 1,52 0a26,26 0,1 1,-52 0" />
<path
android:fillColor="#000000"
android:pathData="M54,54m-20,0a20,20 0,1 1,40 0a20,20 0,1 1,-40 0" />
</vector>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Fallback round launcher icon for API < 26. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#2E7D32"
android:pathData="M54,54m-54,0a54,54 0,1 1,108 0a54,54 0,1 1,-108 0" />
<path
android:fillColor="#111111"
android:pathData="M54,54m-26,0a26,26 0,1 1,52 0a26,26 0,1 1,-52 0" />
<path
android:fillColor="#000000"
android:pathData="M54,54m-20,0a20,20 0,1 1,40 0a20,20 0,1 1,-40 0" />
</vector>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1B5E20</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Pro Libertate Games</string>
</resources>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Compose supplies the real theme; this only needs to be a Material 3
base with no action bar so the app draws its own top bars. -->
<style name="Theme.ProLibertateGames" parent="android:Theme.Material.NoActionBar">
<item name="android:windowNoTitle">true</item>
<item name="android:windowContentOverlay">@null</item>
</style>
</resources>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- The updater downloads the APK into the app's cache dir. -->
<cache-path name="updates" path="." />
</paths>